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

117 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-15 18:01 +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 ..compare_combined import CompareCombined 

9 

10if TYPE_CHECKING: 

11 from ..compare_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( 

80 self, tab_level: int = 0, with_path: bool = True, to_crop: tuple[int, int] = (0, 0) 

81 ) -> str: 

82 header = self._render_start_line(tab_level=tab_level, with_path=with_path, to_crop=to_crop) 

83 

84 dimension = self._detect_dimension() 

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

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

87 if self.status is Statuses.DELETED: 

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

89 

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

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

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

93 

94 @staticmethod 

95 def legend() -> "LEGEND_RETURN_TYPE": 

96 return { 

97 "element": "Ranges", 

98 "description": ( 

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

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

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

102 "∞ - infinity\n" 

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

104 ), 

105 "example": [ 

106 { 

107 "old_value": {}, 

108 "new_value": { 

109 "minimum": 1, 

110 "maximum": 32, 

111 "exclusiveMinimum": True, 

112 "exclusiveMaximum": False, 

113 }, 

114 }, 

115 { 

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

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

118 }, 

119 { 

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

121 "new_value": {}, 

122 }, 

123 { 

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

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

126 }, 

127 ], 

128 } 

129 

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

131 

132 def _detect_dimension(self) -> Dimension: 

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

134 

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

136 return bool(keys.intersection(candidates)) 

137 

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

139 return "length" 

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

141 return "items" 

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

143 return "properties" 

144 return "value" 

145 

146 @staticmethod 

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

148 return { 

149 "value": "range", 

150 "length": "rangeLength", 

151 "items": "rangeItems", 

152 "properties": "rangeProperties", 

153 }[dimension] 

154 

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

156 

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

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

159 if tc is None: 

160 return None 

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

162 

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

164 

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

166 if dimension == "value": 

167 return self._bounds_numbers(side) 

168 elif dimension == "length": 

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

170 elif dimension == "items": 

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

172 else: # dimension == "properties" 

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

174 

175 def _bounds_inclusive_pair( 

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

177 ) -> Bounds: 

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

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

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

181 

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

183 """ 

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

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

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

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

188 """ 

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

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

191 

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

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

194 

195 ex_min_num = self._as_number(ex_min_raw) 

196 ex_max_num = self._as_number(ex_max_raw) 

197 

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

199 lower: Number | None 

200 if ex_min_num is not None: 

201 lower = ex_min_num 

202 lower_inc = False 

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

204 lower = minimum 

205 lower_inc = False 

206 else: 

207 lower = minimum 

208 lower_inc = minimum is not None 

209 

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

211 upper: Number | None 

212 if ex_max_num is not None: 

213 upper = ex_max_num 

214 upper_inc = False 

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

216 upper = maximum 

217 upper_inc = False 

218 else: 

219 upper = maximum 

220 upper_inc = maximum is not None 

221 

222 return Bounds( 

223 lower=lower, 

224 lower_inclusive=lower_inc, 

225 upper=upper, 

226 upper_inclusive=upper_inc, 

227 ) 

228 

229 @staticmethod 

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

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

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

233 return value 

234 return None 

235 

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

237 

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

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

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

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

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

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