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
« 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
4from dataclasses import dataclass
5from typing import TYPE_CHECKING, Any, Literal, Optional, Union
7from ..abstraction import Statuses, ToCompare
8from ..compare_combined import CompareCombined
10if TYPE_CHECKING:
11 from ..compare_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(
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)
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))}"
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}"
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 }
130 # ---- Определение измерения/ключа ----
132 def _detect_dimension(self) -> Dimension:
133 keys = set(self.dict_compare.keys())
135 def has_any(*candidates: str) -> bool:
136 return bool(keys.intersection(candidates))
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"
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]
155 # ---- Извлечение значений (только через ToCompare) ----
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
163 # ---- Построение границ ----
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")
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)
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"))
192 ex_min_raw = self._get_side_value(side, "exclusiveMinimum")
193 ex_max_raw = self._get_side_value(side, "exclusiveMaximum")
195 ex_min_num = self._as_number(ex_min_raw)
196 ex_max_num = self._as_number(ex_max_raw)
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
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
222 return Bounds(
223 lower=lower,
224 lower_inclusive=lower_inc,
225 upper=upper,
226 upper_inclusive=upper_inc,
227 )
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
236 # ---- Форматирование ----
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}"