Coverage for r11k/interval.py: 78%
118 statements
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-13 21:48 +0100
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-13 21:48 +0100
1"""
2Intervals of versions.
4Also includes operations for manipulating and comparing those
5intervals.
6"""
8import operator
10from typing import (
11 Optional,
12)
14from semver import VersionInfo
16from .version import (
17 Comp,
18 max_version,
19 min_version,
20)
23class Interval:
24 """
25 An interval of versions.
27 Keeps information about the endpoints, along with who imposed that
28 constraint.
30 ### Examples and operators
31 Two intervals are equal (with regards to `==`) only if they are
32 identical, including their `max_by` and `min_by` fields.
34 Intervals supports the `in` operator for `VersionInfo` objects,
35 returning if the given version is within the intervals bounds.
37 :param minimi: Lower bound of the interval, default to 0.0.0
38 :param maximi: Upper bound of the interval, defaults to (2⁶³).0.0
39 (which is basically infinity)
40 :param min_cmp:
41 :param max_cmp:
42 :param by: Fallback for both `min_by` and `max_by` if either isn't given.
43 :param min_by:
44 :param max_by:
46 :raises: `ValueError` if `self.minimi > self.maximi`.
47 """
49 def __init__(self,
50 minimi: VersionInfo = min_version,
51 maximi: VersionInfo = max_version,
52 min_cmp: Comp = operator.gt,
53 max_cmp: Comp = operator.le,
54 *,
55 by: Optional[str] = None,
56 min_by: Optional[str] = None,
57 max_by: Optional[str] = None,
58 ):
60 if min_cmp not in [operator.gt, operator.ge]:
61 raise ValueError("Only '>' and '>=' are valid for min_cmp")
62 if max_cmp not in [operator.lt, operator.le]:
63 raise ValueError("Only '<' and '<=' are valid for max_cmp")
65 self.minimi: VersionInfo = minimi
66 """Lower bound for interval."""
67 self.maximi: VersionInfo = maximi
68 """Upper bound for interval."""
69 self.min_cmp: Comp = min_cmp
70 """Comparison operator for the lower bound, *must* be `>` or `≥`."""
71 self.max_cmp: Comp = max_cmp
72 """Comparison operator for the upper bound, *must* be `<` or `≤`."""
74 self.min_by: str = min_by or by or ""
75 """
76 A description of who imposed the lower constraint in this interval.
78 Used to later inspect why this interval looks like it does.
79 Retrival of this field should only be for display to the user,
80 and no assumptions should be made about the format. It will
81 however probably either be the name of a Puppet module, or a
82 comma separated list of them.
83 """
84 self.max_by: str = max_by or by or ""
85 """Like `min_by`, but for the upper bound."""
87 if self.minimi > self.maximi:
88 raise ValueError("The end of an interval must be greater (or equal) to the start")
90 def newest(self, lst: list[VersionInfo]) -> VersionInfo:
91 """Find the largest version from list which is inside this interval."""
92 for v in sorted(lst, reverse=True):
93 if v in self:
94 return v
95 raise ValueError('No matching versions')
97 def __contains__(self, v: VersionInfo) -> bool:
98 return bool(self.min_cmp(v, self.minimi) and self.max_cmp(v, self.maximi))
100 def __eq__(self, other: object) -> bool:
101 if not isinstance(other, Interval):
102 return False
104 return all([self.minimi == other.minimi,
105 self.maximi == other.maximi,
106 self.min_cmp == other.min_cmp,
107 self.max_cmp == other.max_cmp,
108 self.min_by == other.min_by,
109 self.max_by == other.max_by])
111 def __repr__(self) -> str:
112 op = {operator.gt: "operator.gt",
113 operator.ge: "operator.ge",
114 operator.lt: "operator.lt",
115 operator.le: "operator.le"}
116 args = ', '.join([repr(self.minimi),
117 repr(self.maximi),
118 op.get(self.min_cmp, str(self.min_cmp)),
119 op.get(self.max_cmp, str(self.min_cmp)),
120 f'min_by={repr(self.max_by)}',
121 f'max_by={repr(self.max_by)}'])
122 return f'Interval({args})'
124 def __str__(self) -> str:
125 return ''.join([
126 '[' if self.min_cmp == operator.ge else '(',
127 str(self.minimi),
128 ', ',
129 str(self.maximi),
130 ']' if self.max_cmp == operator.le else ')',
131 ' ',
132 f'upper={repr(self.max_by)}, ',
133 f'lower={repr(self.min_by)}',
134 ])
137def __join_opt_strings(*strings: Optional[str]) -> Optional[str]:
138 """Join all non-None strings."""
139 out: list[str] = []
140 for s in strings:
141 if s:
142 out.append(s)
143 if out:
144 return ','.join(out)
145 else:
146 return None
149def overlaps(a: Interval, b: Interval) -> bool:
150 """
151 Check if interval a overlaps interval b.
153 The case where `a.maximi == b.minimi` (or vice versa) only
154 overlaps when both intervals are inclusive in that point.
155 """
156 # Case 1 Case 2 Case 3 Case 4
157 # [---) [---] [---) [---]
158 # (---] (---] [---] [---]
159 if a.maximi == b.minimi:
160 return bool(a.max_cmp == operator.le
161 and b.min_cmp == operator.ge)
162 elif a.minimi == b.maximi:
163 return bool(a.min_cmp == operator.ge
164 and b.max_cmp == operator.le)
165 # Case 9
166 # [---] [---]
167 elif min(a.maximi, b.maximi) < max(a.minimi, b.minimi):
168 return False
170 # Case 5 Case 8
171 # [----] [--]
172 # [---] [---------]
173 elif a.min_cmp(b.minimi, a.minimi) and a.max_cmp(b.minimi, a.maximi):
174 return True
176 # Case 6 Case 7
177 # [----] [--------]
178 # [-----] [--]
179 elif b.min_cmp(a.minimi, b.minimi) and b.max_cmp(a.minimi, b.maximi): 179 ↛ 183line 179 didn't jump to line 183, because the condition on line 179 was never false
180 return True
182 else:
183 return False
186def intersect(a: Interval, b: Interval) -> Interval:
187 """
188 Return the intersection of the two given intervals.
190 :raises: `ValueError` if the two intervals don't overlap.
191 """
192 minimi: VersionInfo
193 maximi: VersionInfo
194 min_by: Optional[str]
195 max_by: Optional[str]
196 min_cmp: Comp
197 max_cmp: Comp
199 if not overlaps(a, b):
200 raise ValueError("Only overlapping intervals can intersect")
202 # Lower Bound
203 if a.minimi == b.minimi:
204 minimi = a.minimi
205 if a.min_cmp == b.min_cmp: 205 ↛ 209line 205 didn't jump to line 209, because the condition on line 205 was never false
206 # pick either one
207 min_cmp = a.min_cmp
208 min_by = __join_opt_strings(a.min_by, b.min_by)
209 elif a.min_cmp == operator.ge and b.min_cmp == operator.gt:
210 min_cmp = b.min_cmp
211 min_by = b.min_by
212 elif b.min_cmp == operator.ge and a.min_cmp == operator.gt:
213 min_cmp = a.min_cmp
214 min_by = a.min_by
215 else:
216 raise ValueError("Invalid comperators")
218 elif a.minimi < b.minimi: 218 ↛ 223line 218 didn't jump to line 223, because the condition on line 218 was never false
219 minimi = a.minimi
220 min_by = a.min_by
221 min_cmp = a.min_cmp
223 elif a.minimi > b.minimi:
224 minimi = b.minimi
225 min_by = b.min_by
226 min_cmp = b.min_cmp
228 # Upper Bound
229 if a.maximi == b.maximi:
230 maximi = a.maximi
231 if a.max_cmp == b.max_cmp: 231 ↛ 234line 231 didn't jump to line 234, because the condition on line 231 was never false
232 max_cmp = a.max_cmp
233 max_by = __join_opt_strings(a.max_by, b.max_by)
234 elif a.max_cmp == operator.gt and b.max_cmp == operator.ge:
235 max_cmp = a.max_cmp
236 max_by = a.max_by
237 elif a.max_cmp == operator.ge and b.max_cmp == operator.gt:
238 max_cmp = b.max_cmp
239 max_by = b.max_by
240 else:
241 raise ValueError("Invalid comperators")
243 elif a.maximi < b.maximi: 243 ↛ 248line 243 didn't jump to line 248, because the condition on line 243 was never false
244 maximi = b.maximi
245 max_by = b.max_by
246 max_cmp = b.max_cmp
248 elif a.maximi > b.maximi:
249 maximi = a.maximi
250 max_by = a.max_by
251 max_cmp = a.max_cmp
253 # Return the result
254 return Interval(minimi, maximi, min_cmp, max_cmp,
255 min_by=min_by, max_by=max_by)
258def parse_interval(s: str,
259 *,
260 by: Optional[str] = None,
261 min_by: Optional[str] = None,
262 max_by: Optional[str] = None,
263 ) -> Interval:
264 """
265 Parse a string as an interval.
267 :param s: String to parse, should be on form `[<start>, <end>]`,
268 where the brackes can be either hard or soft (for closed and
269 open intervals), and start and end must both be parsable by
270 semver.VersionInfo.parse.
271 :param by: Passed verbatim to `Interval`s constructor
272 :param min_by: Passed verbatim to `Interval`s constructor
273 :param max_by: Passed verbatim to `Interval`s constructor
274 """
275 min_cmp: Comp
276 max_cmp: Comp
277 start: VersionInfo
278 end: VersionInfo
280 if s[0] == '[':
281 min_cmp = operator.ge
282 elif s[0] == '(':
283 min_cmp = operator.gt
284 else:
285 raise ValueError(f"Invalid start of interval '{s[0]}'")
287 start, end = [VersionInfo.parse(x.strip()) for x in s[1:-1].split(',')]
289 if s[-1] == ']':
290 max_cmp = operator.le
291 elif s[-1] == ')':
292 max_cmp = operator.lt
293 else:
294 raise ValueError(f"Invalid end of interval '{s[-1]}'")
296 return Interval(start, end, min_cmp, max_cmp,
297 by=by, min_by=min_by, max_by=max_by)