Coverage for src/pytest_patterns/plugin.py: 72%

185 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-10-22 17:59 +0200

1from __future__ import annotations 

2 

3import enum 

4import re 

5from typing import Any, Iterator 

6 

7import pytest 

8 

9 

10@pytest.fixture 

11def patterns() -> PatternsLib: 

12 return PatternsLib() 

13 

14 

15def pytest_assertrepr_compare( 

16 op: str, left: Any, right: Any 

17) -> list[str] | None: 

18 if op != "==": 18 ↛ 19line 18 didn't jump to line 19, because the condition on line 18 was never true

19 return None 

20 if isinstance(left, Pattern): 20 ↛ 22line 20 didn't jump to line 22, because the condition on line 20 was never false

21 return list(left._audit(right).report()) 

22 elif isinstance(right, Pattern): 

23 return list(right._audit(left).report()) 

24 else: 

25 return None 

26 

27 

28class Status(enum.Enum): 

29 UNEXPECTED = 1 

30 OPTIONAL = 2 

31 EXPECTED = 3 

32 REFUSED = 4 

33 

34 @property 

35 def symbol(self) -> str: 

36 return STATUS_SYMBOLS[self] 

37 

38 

39STATUS_SYMBOLS = { 

40 Status.UNEXPECTED: "🟡", 

41 Status.EXPECTED: "🟢", 

42 Status.OPTIONAL: "⚪️", 

43 Status.REFUSED: "🔴", 

44} 

45 

46EMPTY_LINE_PATTERN = "<empty-line>" 

47 

48 

49def tab_replace(line: str) -> str: 

50 while (position := line.find("\t")) != -1: 

51 fill = " " * (8 - (position % 8)) 

52 line = line.replace("\t", fill) 

53 return line 

54 

55 

56ascii_to_control_pictures = { 

57 0x00: "\u2400", # NUL -> ␀ 

58 0x01: "\u2401", # SOH -> ␁ 

59 0x02: "\u2402", # STX -> ␂ 

60 0x03: "\u2403", # ETX -> ␃ 

61 0x04: "\u2404", # EOT -> ␄ 

62 0x05: "\u2405", # ENQ -> ␅ 

63 0x06: "\u2406", # ACK -> ␆ 

64 0x07: "\u2407", # BEL -> ␇ 

65 0x08: "\u2408", # BS -> ␈ 

66 0x09: "\u2409", # HT -> ␉ 

67 0x0A: "\u240a", # LF -> ␊ 

68 0x0B: "\u240b", # VT -> ␋ 

69 0x0C: "\u240c", # FF -> ␌ 

70 0x0D: "\u240d", # CR -> ␍ 

71 0x0E: "\u240e", # SO -> ␎ 

72 0x0F: "\u240f", # SI -> ␏ 

73 0x10: "\u2410", # DLE -> ␐ 

74 0x11: "\u2411", # DC1 -> ␑ 

75 0x12: "\u2412", # DC2 -> ␒ 

76 0x13: "\u2413", # DC3 -> ␓ 

77 0x14: "\u2414", # DC4 -> ␔ 

78 0x15: "\u2415", # NAK -> ␕ 

79 0x16: "\u2416", # SYN -> ␖ 

80 0x17: "\u2417", # ETB -> ␗ 

81 0x18: "\u2418", # CAN -> ␘ 

82 0x19: "\u2419", # EM -> ␙ 

83 0x1A: "\u241a", # SUB -> ␚ 

84 0x1B: "\u241b", # ESC -> ␛ 

85 0x1C: "\u241c", # FS -> ␜ 

86 0x1D: "\u241d", # GS -> ␝ 

87 0x1E: "\u241e", # RS -> ␞ 

88 0x1F: "\u241f", # US -> ␟ 

89 0x20: "\u2420", # SPACE -> ␠ 

90 0x7F: "\u2421", # DEL -> ␡ 

91} 

92 

93 

94def to_control_picture(char: str) -> str: 

95 return ascii_to_control_pictures.get(ord(char), char) 

96 

97 

98def line_to_control_pictures(line: str) -> str: 

99 return "".join(to_control_picture(char) for char in line) 

100 

101 

102def match(pattern: str, line: str) -> bool | re.Match[str] | None: 

103 if pattern == EMPTY_LINE_PATTERN: 

104 if not line: 

105 return True 

106 

107 line = tab_replace(line) 

108 pattern = re.escape(pattern) 

109 pattern = pattern.replace(r"\.\.\.", ".*?") 

110 re_pattern = re.compile("^" + pattern + "$") 

111 return re_pattern.match(line) 

112 

113 

114class Line: 

115 status: Status = Status.UNEXPECTED 

116 status_cause: str = "" 

117 

118 def __init__(self, data: str): 

119 self.data = data 

120 

121 def matches(self, expectation: str) -> bool: 

122 return bool(match(expectation, self.data)) 

123 

124 def mark(self, status: Status, cause: str) -> None: 

125 if status.value <= self.status.value: 

126 # Stay in the current status 

127 return 

128 self.status = status 

129 self.status_cause = cause 

130 

131 

132class Audit: 

133 content: list[Line] 

134 unmatched_expectations: list[tuple[str, str]] 

135 matched_refused: set[tuple[str, str]] 

136 

137 def __init__(self, content: str): 

138 self.unmatched_expectations = [] 

139 self.matched_refused = set() 

140 

141 self.content = [] 

142 for line in content.splitlines(): 

143 self.content.append(Line(line)) 

144 

145 def cursor(self) -> Iterator[Line]: 

146 return iter(self.content) 

147 

148 def in_order(self, name: str, expected_lines: list[str]) -> None: 

149 """Expect all lines exist and come in order, but they 

150 may be interleaved with other lines.""" 

151 cursor = self.cursor() 

152 have_some_match = False 

153 for expected_line in expected_lines: 

154 for line in cursor: 

155 if line.matches(expected_line): 

156 line.mark(Status.EXPECTED, name) 

157 have_some_match = True 

158 break 

159 else: 

160 self.unmatched_expectations.append((name, expected_line)) 

161 if not have_some_match: 161 ↛ 166line 161 didn't jump to line 166, because the condition on line 161 was never true

162 # Reset the scan, if we didn't have any previous 

163 # match - maybe a later line will produce a partial match. 

164 # But do not reset if we already have something matching, 

165 # because that would defeat the "in order" assumption. 

166 cursor = self.cursor() 

167 

168 def optional(self, name: str, tolerated_lines: list[str]) -> None: 

169 """Those lines may exist and then they may appear anywhere 

170 a number of times, or they may not exist. 

171 """ 

172 for tolerated_line in tolerated_lines: 

173 for line in self.cursor(): 

174 if line.matches(tolerated_line): 

175 line.mark(Status.OPTIONAL, name) 

176 

177 def refused(self, name: str, refused_lines: list[str]) -> None: 

178 for refused_line in refused_lines: 

179 for line in self.cursor(): 

180 if line.matches(refused_line): 

181 line.mark(Status.REFUSED, name) 

182 self.matched_refused.add((name, refused_line)) 

183 

184 def continuous(self, name: str, continuous_lines: list[str]) -> None: 

185 continuous_cursor = enumerate(continuous_lines) 

186 continuous_index, continuous_line = next(continuous_cursor) 

187 for line in self.cursor(): 

188 if continuous_index and not line.data: 

189 # Continuity still allows empty lines (after the first line) in 

190 # between as we filter them out from the pattern to make those 

191 # more readable. 

192 line.mark(Status.OPTIONAL, name) 

193 continue 

194 if line.matches(continuous_line): 

195 line.mark(Status.EXPECTED, name) 

196 try: 

197 continuous_index, continuous_line = next(continuous_cursor) 

198 except StopIteration: 

199 # We exhausted the pattern and are happy. 

200 break 

201 elif continuous_index: 

202 # This is not the first focus line any more, it's not valid to 

203 # not match 

204 line.mark(Status.REFUSED, name) 

205 self.unmatched_expectations.append((name, continuous_line)) 

206 self.unmatched_expectations.extend( 

207 [(name, line) for i, line in continuous_cursor] 

208 ) 

209 break 

210 else: 

211 self.unmatched_expectations.append((name, continuous_line)) 

212 self.unmatched_expectations.extend( 

213 [(name, line) for i, line in continuous_cursor] 

214 ) 

215 

216 def report(self) -> Iterator[str]: 

217 yield "String did not meet the expectations." 

218 yield "" 

219 yield " | ".join( 

220 [ 

221 Status.EXPECTED.symbol + "=EXPECTED", 

222 Status.OPTIONAL.symbol + "=OPTIONAL", 

223 Status.UNEXPECTED.symbol + "=UNEXPECTED", 

224 Status.REFUSED.symbol + "=REFUSED/UNMATCHED", 

225 ] 

226 ) 

227 yield "" 

228 yield "Here is the string that was tested: " 

229 yield "" 

230 for line in self.content: 

231 yield format_line_report( 

232 line.status, 

233 line.status.symbol, 

234 line.status_cause, 

235 tab_replace(line.data), 

236 ) 

237 if self.unmatched_expectations: 

238 yield "" 

239 yield "These are the unmatched expected lines: " 

240 yield "" 

241 for name, line_str in self.unmatched_expectations: 

242 yield format_line_report( 

243 Status.REFUSED, Status.REFUSED.symbol, name, line_str 

244 ) 

245 if self.matched_refused: 

246 yield "" 

247 yield "These are the matched refused lines: " 

248 yield "" 

249 for name, line_str in self.matched_refused: 

250 yield format_line_report( 

251 Status.REFUSED, Status.REFUSED.symbol, name, line_str 

252 ) 

253 

254 def is_ok(self) -> bool: 

255 if self.unmatched_expectations: 

256 return False 

257 for line in self.content: 

258 if line.status not in [Status.EXPECTED, Status.OPTIONAL]: 

259 return False 

260 return True 

261 

262 

263# XXX make tab replacement and format with control pictures optional 

264# also, tab replacement only happens on the input line, not the test line 

265# because due to `...` we can't know where the tab will land 

266 

267 

268def format_line_report( 

269 status: Status, 

270 symbol: str, 

271 cause: str, 

272 line: str, 

273) -> str: 

274 if status not in [Status.EXPECTED, Status.OPTIONAL]: 

275 line = line_to_control_pictures(line) 

276 return symbol + " " + cause.ljust(15)[:15] + " | " + line 

277 

278 

279def pattern_lines(lines: str) -> list[str]: 

280 # Remove leading whitespace, ignore empty lines. 

281 return list(filter(None, lines.splitlines())) 

282 

283 

284class Pattern: 

285 name: str 

286 library: PatternsLib 

287 ops: list[tuple[str, str, Any]] 

288 inherited: set[str] 

289 

290 def __init__(self, library: PatternsLib, name: str): 

291 self.name = name 

292 self.library = library 

293 self.ops = [] 

294 self.inherited = set() 

295 

296 # Modifiers (Verbs) 

297 

298 def merge(self, *base_patterns: str) -> None: 

299 """Merge rules from base_patterns (recursively) into this pattern.""" 

300 self.inherited.update(base_patterns) 

301 

302 def normalize(self, mode: str) -> None: 

303 pass 

304 

305 # Matches (Adjectives) 

306 

307 def continuous(self, lines: str) -> None: 

308 """These lines must appear once and they must be continuous.""" 

309 self.ops.append(("continuous", self.name, pattern_lines(lines))) 

310 

311 def in_order(self, lines: str) -> None: 

312 """These lines must appear once and they must be in order.""" 

313 self.ops.append(("in_order", self.name, pattern_lines(lines))) 

314 

315 def optional(self, lines: str) -> None: 

316 """These lines are optional.""" 

317 self.ops.append(("optional", self.name, pattern_lines(lines))) 

318 

319 def refused(self, lines: str) -> None: 

320 """If those lines appear they are refused.""" 

321 self.ops.append(("refused", self.name, pattern_lines(lines))) 

322 

323 # Internal API 

324 

325 def flat_ops(self) -> Iterator[tuple[str, str, Any]]: 

326 for inherited_pattern in self.inherited: 

327 yield from getattr(self.library, inherited_pattern).flat_ops() 

328 yield from self.ops 

329 

330 def _audit(self, content: str) -> Audit: 

331 audit = Audit(content) 

332 for op, *args in self.flat_ops(): 

333 getattr(audit, op)(*args) 

334 return audit 

335 

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

337 assert isinstance(other, str) 

338 audit = self._audit(other) 

339 return audit.is_ok() 

340 

341 

342class PatternsLib: 

343 def __getattr__(self, name: str) -> Pattern: 

344 res = self.__dict__[name] = Pattern(self, name) 

345 return res