Coverage for jsonschema_diff/core/custom_compare/range.py: 91%

117 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-25 07:00 +0000

1# jsonschema_diff/custom_compare/range.py 

2from __future__ import annotations 

3 

4from dataclasses import dataclass 

5from typing import TYPE_CHECKING, Any, Literal, Optional, Union 

6 

7from ..abstraction import Statuses, ToCompare 

8from ..parameter_combined import CompareCombined 

9 

10if TYPE_CHECKING: 

11 from ..parameter_base import LEGEND_RETURN_TYPE 

12 

13 

14Number = Union[int, float] 

15Dimension = Literal["value", "length", "items", "properties"] 

16 

17 

18@dataclass(frozen=True) 

19class Bounds: 

20 lower: Optional[Number] 

21 lower_inclusive: bool 

22 upper: Optional[Number] 

23 upper_inclusive: bool 

24 

25 def is_empty(self) -> bool: 

26 return self.lower is None and self.upper is None 

27 

28 

29class CompareRange(CompareCombined): 

30 """ 

31 Ranges for JSON Schema: 

32 - value: minimum/maximum (+ exclusiveMinimum/Maximum: bool|number) 

33 - length: minLength/maxLength 

34 - items: minItems/maxItems 

35 - properties: minProperties/maxProperties 

36 

37 Notes: 

38 - bool is not considered a number (excluded from isinstance(int)) 

39 - use only dict_compare (ToCompare by keys) 

40 """ 

41 

42 INFINITY = "∞" 

43 

44 # ---- Жизненный цикл ---- 

45 

46 def compare(self) -> Statuses: 

47 super().compare() 

48 

49 dimension = self._detect_dimension() 

50 old_b = self._bounds_for_side("old", dimension) 

51 new_b = self._bounds_for_side("new", dimension) 

52 

53 if old_b.is_empty() and new_b.is_empty(): 

54 self.status = Statuses.NO_DIFF 

55 return self.status 

56 if old_b.is_empty() and not new_b.is_empty(): 

57 self.status = Statuses.ADDED 

58 return self.status 

59 if not old_b.is_empty() and new_b.is_empty(): 

60 self.status = Statuses.DELETED 

61 return self.status 

62 

63 if ( 

64 old_b.lower == new_b.lower 

65 and old_b.upper == new_b.upper 

66 and old_b.lower_inclusive == new_b.lower_inclusive 

67 and old_b.upper_inclusive == new_b.upper_inclusive 

68 ): 

69 self.status = Statuses.NO_DIFF 

70 else: 

71 self.status = Statuses.REPLACED 

72 

73 return self.status 

74 

75 def get_name(self) -> str: 

76 dimension = self._detect_dimension() 

77 return self._key_for_dimension(dimension) 

78 

79 def render(self, tab_level: int = 0, with_path: bool = True) -> str: 

80 header = self._render_start_line(tab_level=tab_level, with_path=with_path) 

81 

82 dimension = self._detect_dimension() 

83 if self.status in (Statuses.ADDED, Statuses.NO_DIFF): 

84 return f"{header} {self._format_bounds(self._bounds_for_side('new', dimension))}" 

85 if self.status is Statuses.DELETED: 

86 return f"{header} {self._format_bounds(self._bounds_for_side('old', dimension))}" 

87 

88 old_repr = self._format_bounds(self._bounds_for_side("old", dimension)) 

89 new_repr = self._format_bounds(self._bounds_for_side("new", dimension)) 

90 return f"{header} {old_repr} -> {new_repr}" 

91 

92 @staticmethod 

93 def legend() -> "LEGEND_RETURN_TYPE": 

94 return { 

95 "element": "Ranges", 

96 "description": ( 

97 "Range - custom render for min/max/exclusiveMin/exclusiveMax fields, " 

98 "as well as all their analogues for strings/arrays/objects.\n\n" 

99 "[] - inclusive, () - exclusive\n" 

100 "∞ - infinity\n" 

101 "The principle is the same as it was taught in school." 

102 ), 

103 "example": [ 

104 { 

105 "old_value": {}, 

106 "new_value": { 

107 "minimum": 1, 

108 "maximum": 32, 

109 "exclusiveMinimum": True, 

110 "exclusiveMaximum": False, 

111 }, 

112 }, 

113 { 

114 "old_value": {"minProperties": 1}, 

115 "new_value": {"minProperties": 1, "maxProperties": 32}, 

116 }, 

117 { 

118 "old_value": {"minItems": 1, "maxItems": 32}, 

119 "new_value": {}, 

120 }, 

121 { 

122 "old_value": {"minLength": 1, "maxLength": 32}, 

123 "new_value": {"minLength": 5, "maxLength": 10}, 

124 }, 

125 ], 

126 } 

127 

128 # ---- Определение измерения/ключа ---- 

129 

130 def _detect_dimension(self) -> Dimension: 

131 keys = set(self.dict_compare.keys()) 

132 

133 def has_any(*candidates: str) -> bool: 

134 return bool(keys.intersection(candidates)) 

135 

136 if has_any("minLength", "maxLength"): 

137 return "length" 

138 if has_any("minItems", "maxItems"): 

139 return "items" 

140 if has_any("minProperties", "maxProperties"): 

141 return "properties" 

142 return "value" 

143 

144 @staticmethod 

145 def _key_for_dimension(dimension: Dimension) -> str: 

146 return { 

147 "value": "range", 

148 "length": "rangeLength", 

149 "items": "rangeItems", 

150 "properties": "rangeProperties", 

151 }[dimension] 

152 

153 # ---- Извлечение значений (только через ToCompare) ---- 

154 

155 def _get_side_value(self, side: Literal["old", "new"], key: str) -> Any: 

156 tc: ToCompare | None = self.dict_compare.get(key) 

157 if tc is None: 

158 return None 

159 return tc.old_value if side == "old" else tc.new_value 

160 

161 # ---- Построение границ ---- 

162 

163 def _bounds_for_side(self, side: Literal["old", "new"], dimension: Dimension) -> Bounds: 

164 if dimension == "value": 

165 return self._bounds_numbers(side) 

166 elif dimension == "length": 

167 return self._bounds_inclusive_pair(side, "minLength", "maxLength") 

168 elif dimension == "items": 

169 return self._bounds_inclusive_pair(side, "minItems", "maxItems") 

170 else: # dimension == "properties" 

171 return self._bounds_inclusive_pair(side, "minProperties", "maxProperties") 

172 

173 def _bounds_inclusive_pair( 

174 self, side: Literal["old", "new"], low_key: str, high_key: str 

175 ) -> Bounds: 

176 lower = self._as_number(self._get_side_value(side, low_key)) 

177 upper = self._as_number(self._get_side_value(side, high_key)) 

178 return Bounds(lower=lower, lower_inclusive=True, upper=upper, upper_inclusive=True) 

179 

180 def _bounds_numbers(self, side: Literal["old", "new"]) -> Bounds: 

181 """ 

182 Поддержка двух форматов exclusive*: 

183 - numeric (draft-06+): exclusiveMinimum/Maximum = number 

184 - boolean (старый): exclusiveMinimum/Maximum = true/false вместе с minimum/maximum 

185 Приоритет у числового формата. 

186 """ 

187 minimum = self._as_number(self._get_side_value(side, "minimum")) 

188 maximum = self._as_number(self._get_side_value(side, "maximum")) 

189 

190 ex_min_raw = self._get_side_value(side, "exclusiveMinimum") 

191 ex_max_raw = self._get_side_value(side, "exclusiveMaximum") 

192 

193 ex_min_num = self._as_number(ex_min_raw) 

194 ex_max_num = self._as_number(ex_max_raw) 

195 

196 # нижняя граница 

197 lower: Number | None 

198 if ex_min_num is not None: 

199 lower = ex_min_num 

200 lower_inc = False 

201 elif isinstance(ex_min_raw, bool) and ex_min_raw and minimum is not None: 

202 lower = minimum 

203 lower_inc = False 

204 else: 

205 lower = minimum 

206 lower_inc = minimum is not None 

207 

208 # верхняя граница 

209 upper: Number | None 

210 if ex_max_num is not None: 

211 upper = ex_max_num 

212 upper_inc = False 

213 elif isinstance(ex_max_raw, bool) and ex_max_raw and maximum is not None: 

214 upper = maximum 

215 upper_inc = False 

216 else: 

217 upper = maximum 

218 upper_inc = maximum is not None 

219 

220 return Bounds( 

221 lower=lower, 

222 lower_inclusive=lower_inc, 

223 upper=upper, 

224 upper_inclusive=upper_inc, 

225 ) 

226 

227 @staticmethod 

228 def _as_number(value: object | None) -> Optional[Number]: 

229 # bool — подкласс int, исключаем 

230 if isinstance(value, (int, float)) and not isinstance(value, bool): 

231 return value 

232 return None 

233 

234 # ---- Форматирование ---- 

235 

236 def _format_bounds(self, b: Bounds) -> str: 

237 left = "[" if b.lower is not None and b.lower_inclusive else "(" 

238 right = "]" if b.upper is not None and b.upper_inclusive else ")" 

239 lo = str(b.lower) if b.lower is not None else f"-{self.INFINITY}" 

240 hi = str(b.upper) if b.upper is not None else self.INFINITY 

241 return f"{left}{lo} ... {hi}{right}"