Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/numeric.py: 50%

128 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1import logging 

2import numbers 

3import sys 

4import typing as t 

5import warnings 

6 

7from viur.core import db, i18n 

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

9 

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 

12 

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

21 

22 

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" 

29 

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. 

41 

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) 

47 

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 ) 

53 

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 

60 

61 self.precision = precision 

62 self.min = min 

63 self.max = max 

64 

65 def __setattr__(self, key, value): 

66 """ 

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

68 

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. 

72 

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

80 

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

82 

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 

89 

90 return val 

91 

92 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool): 

93 return self.singleValueUnserialize(value) # same logic for unserialize here! 

94 

95 def isInvalid(self, value): 

96 """ 

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

98 

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" 

104 

105 def getEmptyValue(self): 

106 """ 

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

108 instance. 

109 

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 

117 

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. 

123 

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

134 

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(ReadFromClientErrorSeverity.Invalid)] 

142 

143 # Convert to float or int -- depending on the precision 

144 # Since we convert direct to int if precision=0, a float value isn't valid 

145 try: 

146 value = float(value) if self.precision else int(value) 

147 except ValueError: 

148 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)] 

149 

150 if self.precision: 

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

152 else: 

153 value = int(value) 

154 

155 # Check the limits after rounding, as the rounding may change the value. 

156 if not (self.min <= value <= self.max): 

157 return self.getEmptyValue(), [ 

158 ReadFromClientError( 

159 ReadFromClientErrorSeverity.Invalid, 

160 i18n.translate( 

161 "core.bones.error.minmax" 

162 "Value not between {{min}} and {{max}}", 

163 default_variables={ 

164 "min": self.min, 

165 "max": self.max, 

166 } 

167 ) 

168 ) 

169 ] 

170 

171 if err := self.isInvalid(value): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)] 

173 

174 return value, None 

175 

176 def buildDBFilter( 

177 self, 

178 name: str, 

179 skel: "SkeletonInstance", 

180 dbFilter: db.Query, 

181 rawFilter: dict, 

182 prefix: t.Optional[str] = None 

183 ) -> db.Query: 

184 updatedFilter = {} 

185 

186 for parmKey, paramValue in rawFilter.items(): 

187 if parmKey.startswith(name): 

188 if parmKey != name and not parmKey.startswith(name + "$"): 

189 # It's just another bone which name start's with our's 

190 continue 

191 try: 

192 if not self.precision: 

193 paramValue = int(paramValue) 

194 else: 

195 paramValue = float(paramValue) 

196 except ValueError: 

197 # The value we should filter by is garbage, cancel this query 

198 logging.warning(f"Invalid filtering! Unparsable int/float supplied to NumericBone {name}") 

199 raise RuntimeError() 

200 updatedFilter[parmKey] = paramValue 

201 

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

203 

204 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]: 

205 """ 

206 This method generates a set of search tags based on the numeric values stored in the NumericBone 

207 instance. It iterates through the bone values and adds the string representation of each value 

208 to the result set. 

209 

210 :param skel: The skeleton instance containing the bone. 

211 :param name: The name of the bone. 

212 :return: Returns a set of search tags as strings. 

213 """ 

214 result = set() 

215 for idx, lang, value in self.iter_bone_value(skel, name): 

216 if value is None: 

217 continue 

218 result.add(str(value)) 

219 return result 

220 

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

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

223 

224 If the value is not convertable an exception will be raised.""" 

225 if isinstance(value, db.Entity | dict) and "val" in value: 

226 value = value["val"] # was a StringBone before 

227 if isinstance(value, str): 

228 value = value.replace(",", ".", 1) 

229 if self.precision: 

230 return round(float(value), self.precision) 

231 else: 

232 # First convert to float then to int to support "42.5" (str) 

233 return int(float(value)) 

234 

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

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

237 

238 This ensures numeric values, for example after changing 

239 a bone from StringBone to a NumericBone. 

240 """ 

241 super().refresh(skel, boneName) 

242 

243 def refresh_single_value(value: t.Any) -> float | int: 

244 if value == "": 

245 return self.getEmptyValue() 

246 elif not isinstance(value, (int, float, type(None))): 

247 return self._convert_to_numeric(value) 

248 return value 

249 

250 # TODO: duplicate code, this is the same iteration logic as in StringBone 

251 new_value = {} 

252 for _, lang, value in self.iter_bone_value(skel, boneName): 

253 new_value.setdefault(lang, []).append(refresh_single_value(value)) 

254 

255 if not self.multiple: 

256 # take the first one 

257 new_value = {lang: values[0] for lang, values in new_value.items() if values} 

258 

259 if self.languages: 

260 skel[boneName] = new_value 

261 elif not self.languages: 

262 # just the value(s) with None language 

263 skel[boneName] = new_value.get(None, [] if self.multiple else self.getEmptyValue()) 

264 

265 def iter_bone_value( 

266 self, skel: "SkeletonInstance", name: str 

267 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]: 

268 value = skel[name] 

269 if not value and isinstance(value, numbers.Number): 

270 # 0 and 0.0 are falsy, but can be valid numeric values and should be kept 

271 yield None, None, value 

272 yield from super().iter_bone_value(skel, name) 

273 

274 def structure(self) -> dict: 

275 return super().structure() | { 

276 "min": self.min, 

277 "max": self.max, 

278 "precision": self.precision, 

279 }