Coverage for r11k/interval.py: 78%

118 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-13 21:48 +0100

1""" 

2Intervals of versions. 

3 

4Also includes operations for manipulating and comparing those 

5intervals. 

6""" 

7 

8import operator 

9 

10from typing import ( 

11 Optional, 

12) 

13 

14from semver import VersionInfo 

15 

16from .version import ( 

17 Comp, 

18 max_version, 

19 min_version, 

20) 

21 

22 

23class Interval: 

24 """ 

25 An interval of versions. 

26 

27 Keeps information about the endpoints, along with who imposed that 

28 constraint. 

29 

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. 

33 

34 Intervals supports the `in` operator for `VersionInfo` objects, 

35 returning if the given version is within the intervals bounds. 

36 

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: 

45 

46 :raises: `ValueError` if `self.minimi > self.maximi`. 

47 """ 

48 

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 ): 

59 

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") 

64 

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 `≤`.""" 

73 

74 self.min_by: str = min_by or by or "" 

75 """ 

76 A description of who imposed the lower constraint in this interval. 

77 

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.""" 

86 

87 if self.minimi > self.maximi: 

88 raise ValueError("The end of an interval must be greater (or equal) to the start") 

89 

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') 

96 

97 def __contains__(self, v: VersionInfo) -> bool: 

98 return bool(self.min_cmp(v, self.minimi) and self.max_cmp(v, self.maximi)) 

99 

100 def __eq__(self, other: object) -> bool: 

101 if not isinstance(other, Interval): 

102 return False 

103 

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]) 

110 

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})' 

123 

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 ]) 

135 

136 

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 

147 

148 

149def overlaps(a: Interval, b: Interval) -> bool: 

150 """ 

151 Check if interval a overlaps interval b. 

152 

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 

169 

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 

175 

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 

181 

182 else: 

183 return False 

184 

185 

186def intersect(a: Interval, b: Interval) -> Interval: 

187 """ 

188 Return the intersection of the two given intervals. 

189 

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 

198 

199 if not overlaps(a, b): 

200 raise ValueError("Only overlapping intervals can intersect") 

201 

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") 

217 

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 

222 

223 elif a.minimi > b.minimi: 

224 minimi = b.minimi 

225 min_by = b.min_by 

226 min_cmp = b.min_cmp 

227 

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") 

242 

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 

247 

248 elif a.maximi > b.maximi: 

249 maximi = a.maximi 

250 max_by = a.max_by 

251 max_cmp = a.max_cmp 

252 

253 # Return the result 

254 return Interval(minimi, maximi, min_cmp, max_cmp, 

255 min_by=min_by, max_by=max_by) 

256 

257 

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. 

266 

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 

279 

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]}'") 

286 

287 start, end = [VersionInfo.parse(x.strip()) for x in s[1:-1].split(',')] 

288 

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]}'") 

295 

296 return Interval(start, end, min_cmp, max_cmp, 

297 by=by, min_by=min_by, max_by=max_by)