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
« 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
4from dataclasses import dataclass
5from typing import TYPE_CHECKING, Any, Literal, Optional, Union
7from ..abstraction import Statuses, ToCompare
8from ..parameter_combined import CompareCombined
10if TYPE_CHECKING:
11 from ..parameter_base import LEGEND_RETURN_TYPE
14Number = Union[int, float]
15Dimension = Literal["value", "length", "items", "properties"]
18@dataclass(frozen=True)
19class Bounds:
20 lower: Optional[Number]
21 lower_inclusive: bool
22 upper: Optional[Number]
23 upper_inclusive: bool
25 def is_empty(self) -> bool:
26 return self.lower is None and self.upper is None
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
37 Notes:
38 - bool is not considered a number (excluded from isinstance(int))
39 - use only dict_compare (ToCompare by keys)
40 """
42 INFINITY = "∞"
44 # ---- Жизненный цикл ----
46 def compare(self) -> Statuses:
47 super().compare()
49 dimension = self._detect_dimension()
50 old_b = self._bounds_for_side("old", dimension)
51 new_b = self._bounds_for_side("new", dimension)
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
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
73 return self.status
75 def get_name(self) -> str:
76 dimension = self._detect_dimension()
77 return self._key_for_dimension(dimension)
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)
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))}"
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}"
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 }
128 # ---- Определение измерения/ключа ----
130 def _detect_dimension(self) -> Dimension:
131 keys = set(self.dict_compare.keys())
133 def has_any(*candidates: str) -> bool:
134 return bool(keys.intersection(candidates))
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"
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]
153 # ---- Извлечение значений (только через ToCompare) ----
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
161 # ---- Построение границ ----
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")
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)
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"))
190 ex_min_raw = self._get_side_value(side, "exclusiveMinimum")
191 ex_max_raw = self._get_side_value(side, "exclusiveMaximum")
193 ex_min_num = self._as_number(ex_min_raw)
194 ex_max_num = self._as_number(ex_max_raw)
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
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
220 return Bounds(
221 lower=lower,
222 lower_inclusive=lower_inc,
223 upper=upper,
224 upper_inclusive=upper_inc,
225 )
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
234 # ---- Форматирование ----
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}"