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

1import logging 

2import numbers 

3import sys 

4import typing as t 

5import warnings 

6import decimal as deci 

7 

8from viur.core import db, i18n 

9from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity 

10 

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 

13 

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""" 

22 

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.""" 

34 

35 

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" 

42 

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. 

56 

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) 

68 

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 ) 

74 

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 

81 

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") 

93 

94 def __setattr__(self, key, value): 

95 """ 

96 Sets the attribute with the specified key to the given value. 

97 

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. 

101 

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}") 

109 

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}") 

112 

113 return super().__setattr__(key, value) 

114 

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") 

133 

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 

144 

145 return val 

146 

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! 

154 

155 def isInvalid(self, value): 

156 """ 

157 This method checks if a given value is invalid (e.g., NaN) for the NumericBone instance. 

158 

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" 

164 

165 def getEmptyValue(self): 

166 """ 

167 This method returns an empty value depending on the precision attribute of the NumericBone 

168 instance. 

169 

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 

179 

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. 

185 

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() 

201 

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 ] 

216 

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)] 

224 

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)] 

231 

232 if self.precision: 

233 value = round(float(value), self.precision) 

234 else: 

235 value = int(value) 

236 

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 ] 

252 

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)] 

255 

256 return value, None 

257 

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 

284 

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 

304 

305 return super().buildDBFilter(name, skel, dbFilter, updatedFilter, prefix) 

306 

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. 

312 

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 

323 

324 def _convert_to_numeric(self, value: t.Any) -> int | float: 

325 """Convert a value to an int or float considering the precision. 

326 

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)) 

337 

338 def refresh(self, skel: "SkeletonInstance", boneName: str) -> None: 

339 """Ensure the value is numeric or None. 

340 

341 This ensures numeric values, for example after changing 

342 a bone from StringBone to a NumericBone. 

343 """ 

344 super().refresh(skel, boneName) 

345 

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 

355 

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)) 

360 

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} 

364 

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()) 

370 

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) 

379 

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, 

386 

387 }