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

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 

23 

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" 

30 

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. 

43 

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) 

50 

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 ) 

56 

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 

63 

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 

70 

71 def __setattr__(self, key, value): 

72 """ 

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

74 

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. 

78 

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

86 

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

88 

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

105 

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 

116 

117 return val 

118 

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! 

126 

127 def isInvalid(self, value): 

128 """ 

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

130 

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" 

136 

137 def getEmptyValue(self): 

138 """ 

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

140 instance. 

141 

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 

151 

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. 

157 

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

173 

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 ] 

188 

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

196 

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

203 

204 if self.precision: 

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

206 else: 

207 value = int(value) 

208 

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 ] 

224 

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

227 

228 return value, None 

229 

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 

256 

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 

276 

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

278 

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. 

284 

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 

295 

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

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

298 

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

309 

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

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

312 

313 This ensures numeric values, for example after changing 

314 a bone from StringBone to a NumericBone. 

315 """ 

316 super().refresh(skel, boneName) 

317 

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 

327 

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

332 

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} 

336 

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

342 

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) 

351 

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, 

358 

359 }