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

118 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-16 22:16 +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 isInvalid(self, value): 

84 """ 

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

86 

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

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

89 """ 

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

91 return "NaN not allowed" 

92 

93 def getEmptyValue(self): 

94 """ 

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

96 instance. 

97 

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

99 precision is non-zero). 

100 """ 

101 if self.precision: 

102 return 0.0 

103 else: 

104 return 0 

105 

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

107 """ 

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

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

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

111 

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

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

114 """ 

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

116 return True 

117 try: 

118 value = self._convert_to_numeric(value) 

119 except (ValueError, TypeError): 

120 return True 

121 return value == self.getEmptyValue() 

122 

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

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

125 # Replace , with . 

126 try: 

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

128 except TypeError: 

129 return self.getEmptyValue(), [ReadFromClientError( 

130 ReadFromClientErrorSeverity.Invalid, "Cannot handle this value" 

131 )] 

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

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

134 try: 

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

136 except ValueError: 

137 return self.getEmptyValue(), [ReadFromClientError( 

138 ReadFromClientErrorSeverity.Invalid, 

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

140 )] 

141 

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

143 if self.precision: 

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

145 else: 

146 value = int(value) 

147 

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

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

150 return self.getEmptyValue(), [ReadFromClientError( 

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

152 )] 

153 

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

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

156 

157 return value, None 

158 

159 def buildDBFilter( 

160 self, 

161 name: str, 

162 skel: "SkeletonInstance", 

163 dbFilter: db.Query, 

164 rawFilter: dict, 

165 prefix: t.Optional[str] = None 

166 ) -> db.Query: 

167 updatedFilter = {} 

168 

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

170 if parmKey.startswith(name): 

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

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

173 continue 

174 try: 

175 if not self.precision: 

176 paramValue = int(paramValue) 

177 else: 

178 paramValue = float(paramValue) 

179 except ValueError: 

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

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

182 raise RuntimeError() 

183 updatedFilter[parmKey] = paramValue 

184 

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

186 

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

188 """ 

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

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

191 to the result set. 

192 

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

194 :param name: The name of the bone. 

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

196 """ 

197 result = set() 

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

199 if value is None: 

200 continue 

201 result.add(str(value)) 

202 return result 

203 

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

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

206 

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

208 if isinstance(value, str): 

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

210 if self.precision: 

211 return float(value) 

212 else: 

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

214 return int(float(value)) 

215 

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

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

218 

219 This ensures numeric values, for example after changing 

220 a bone from StringBone to a NumericBone. 

221 """ 

222 super().refresh(skel, boneName) 

223 

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

225 if value == "": 

226 return self.getEmptyValue() 

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

228 return self._convert_to_numeric(value) 

229 return value 

230 

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

232 new_value = {} 

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

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

235 

236 if not self.multiple: 

237 # take the first one 

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

239 

240 if self.languages: 

241 skel[boneName] = new_value 

242 elif not self.languages: 

243 # just the value(s) with None language 

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

245 

246 def iter_bone_value( 

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

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

249 value = skel[name] 

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

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

252 yield None, None, value 

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

254 

255 def structure(self) -> dict: 

256 return super().structure() | { 

257 "min": self.min, 

258 "max": self.max, 

259 "precision": self.precision, 

260 }