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

129 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-27 07:59 +0000

1import logging 

2import numbers 

3import sys 

4import typing as t 

5import warnings 

6 

7from viur.core import db 

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 if self.precision: 

87 return float(f"{val:.{self.precision}f}") 

88 

89 return int(val) 

90 except ValueError: 

91 return self.getDefaultValue() 

92 

93 return val 

94 

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

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

97 

98 def isInvalid(self, value): 

99 """ 

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

101 

102 :param value: The value to be checked for validity. 

103 :return: Returns a string "NaN not allowed" if the value is invalid (NaN), otherwise None. 

104 """ 

105 if value != value: # NaN 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 return "NaN not allowed" 

107 

108 def getEmptyValue(self): 

109 """ 

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

111 instance. 

112 

113 :return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when 

114 precision is non-zero). 

115 """ 

116 if self.precision: 

117 return 0.0 

118 else: 

119 return 0 

120 

121 def isEmpty(self, value: t.Any): 

122 """ 

123 This method checks if a given raw value is considered empty for the NumericBone instance. 

124 It attempts to convert the raw value into a valid numeric value (integer or floating-point 

125 number), depending on the precision attribute of the NumericBone instance. 

126 

127 :param value: The raw value to be checked for emptiness. 

128 :return: Returns True if the raw value is considered empty, otherwise False. 

129 """ 

130 if isinstance(value, str) and not value: 

131 return True 

132 try: 

133 value = self._convert_to_numeric(value) 

134 except (ValueError, TypeError): 

135 return True 

136 return value == self.getEmptyValue() 

137 

138 def singleValueFromClient(self, value, skel, bone_name, client_data): 

139 if not isinstance(value, (int, float)): 

140 # Replace , with . 

141 try: 

142 value = str(value).replace(",", ".", 1) 

143 except TypeError: 

144 return self.getEmptyValue(), [ReadFromClientError( 

145 ReadFromClientErrorSeverity.Invalid, "Cannot handle this value" 

146 )] 

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

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

149 try: 

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

151 except ValueError: 

152 return self.getEmptyValue(), [ReadFromClientError( 

153 ReadFromClientErrorSeverity.Invalid, 

154 f'Not a valid {"float" if self.precision else "int"} value' 

155 )] 

156 

157 assert isinstance(value, (int, float)) 

158 if self.precision: 

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

160 else: 

161 value = int(value) 

162 

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

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

165 return self.getEmptyValue(), [ReadFromClientError( 

166 ReadFromClientErrorSeverity.Invalid, f"Value not between {self.min} and {self.max}" 

167 )] 

168 

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

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

171 

172 return value, None 

173 

174 def buildDBFilter( 

175 self, 

176 name: str, 

177 skel: "SkeletonInstance", 

178 dbFilter: db.Query, 

179 rawFilter: dict, 

180 prefix: t.Optional[str] = None 

181 ) -> db.Query: 

182 updatedFilter = {} 

183 

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

185 if parmKey.startswith(name): 

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

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

188 continue 

189 try: 

190 if not self.precision: 

191 paramValue = int(paramValue) 

192 else: 

193 paramValue = float(paramValue) 

194 except ValueError: 

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

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

197 raise RuntimeError() 

198 updatedFilter[parmKey] = paramValue 

199 

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

201 

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

203 """ 

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

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

206 to the result set. 

207 

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

209 :param name: The name of the bone. 

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

211 """ 

212 result = set() 

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

214 if value is None: 

215 continue 

216 result.add(str(value)) 

217 return result 

218 

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

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

221 

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

223 if isinstance(value, str): 

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

225 if self.precision: 

226 return float(value) 

227 else: 

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

229 return int(float(value)) 

230 

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

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

233 

234 This ensures numeric values, for example after changing 

235 a bone from StringBone to a NumericBone. 

236 """ 

237 super().refresh(skel, boneName) 

238 

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

240 if value == "": 

241 return self.getEmptyValue() 

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

243 return self._convert_to_numeric(value) 

244 return value 

245 

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

247 new_value = {} 

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

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

250 

251 if not self.multiple: 

252 # take the first one 

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

254 

255 if self.languages: 

256 skel[boneName] = new_value 

257 elif not self.languages: 

258 # just the value(s) with None language 

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

260 

261 def iter_bone_value( 

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

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

264 value = skel[name] 

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

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

267 yield None, None, value 

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

269 

270 def structure(self) -> dict: 

271 return super().structure() | { 

272 "min": self.min, 

273 "max": self.max, 

274 "precision": self.precision, 

275 }