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

178 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-10-18 23:38 +0200

1from __future__ import annotations 

2 

3import enum 

4import re 

5from typing import Any, Iterator, List, Set, Tuple 

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 

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

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

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

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

52 return line 

53 

54 

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

56 if pattern == EMPTY_LINE_PATTERN: 

57 if not line: 

58 return True 

59 

60 line = tab_replace(line) 

61 pattern = re.escape(pattern) 

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

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

64 return re_pattern.match(line) 

65 

66 

67class Line: 

68 status: Status = Status.UNEXPECTED 

69 status_cause: str = "" 

70 

71 def __init__(self, data: str): 

72 self.data = data 

73 

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

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

76 

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

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

79 # Stay in the current status 

80 return 

81 self.status = status 

82 self.status_cause = cause 

83 

84 

85class Audit: 

86 content: list[Line] 

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

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

89 

90 def __init__(self, content: str): 

91 self.unmatched_expectations = [] 

92 self.matched_refused = set() 

93 

94 self.content = [] 

95 for line in content.splitlines(): 

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

97 

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

99 return iter(self.content) 

100 

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

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

103 may be interleaved with other lines.""" 

104 cursor = self.cursor() 

105 have_some_match = False 

106 for expected_line in expected_lines: 

107 for line in cursor: 

108 if line.matches(expected_line): 

109 line.mark(Status.EXPECTED, name) 

110 have_some_match = True 

111 break 

112 else: 

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

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

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

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

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

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

119 cursor = self.cursor() 

120 

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

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

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

124 """ 

125 for tolerated_line in tolerated_lines: 

126 for line in self.cursor(): 

127 if line.matches(tolerated_line): 

128 line.mark(Status.OPTIONAL, name) 

129 

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

131 for refused_line in refused_lines: 

132 for line in self.cursor(): 

133 if line.matches(refused_line): 

134 line.mark(Status.REFUSED, name) 

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

136 

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

138 continuous_cursor = enumerate(continuous_lines) 

139 continuous_index, continuous_line = next(continuous_cursor) 

140 for line in self.cursor(): 

141 if continuous_index and not line.data: 

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

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

144 # more readable. 

145 line.mark(Status.OPTIONAL, name) 

146 continue 

147 if line.matches(continuous_line): 

148 line.mark(Status.EXPECTED, name) 

149 try: 

150 continuous_index, continuous_line = next(continuous_cursor) 

151 except StopIteration: 

152 # We exhausted the pattern and are happy. 

153 break 

154 elif continuous_index: 

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

156 # not match 

157 line.mark(Status.REFUSED, name) 

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

159 self.unmatched_expectations.extend( 

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

161 ) 

162 break 

163 else: 

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

165 self.unmatched_expectations.extend( 

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

167 ) 

168 

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

170 yield "String did not meet the expectations." 

171 yield "" 

172 yield " | ".join( 

173 [ 

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

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

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

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

178 ] 

179 ) 

180 yield "" 

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

182 yield "" 

183 for line in self.content: 

184 yield format_line_report( 

185 line.status.symbol, line.status_cause, line.data 

186 ) 

187 if self.unmatched_expectations: 

188 yield "" 

189 yield "These are the unmatched expected lines: " 

190 yield "" 

191 for name, line_str in self.unmatched_expectations: 

192 yield format_line_report(Status.REFUSED.symbol, name, line_str) 

193 if self.matched_refused: 

194 yield "" 

195 yield "These are the matched refused lines: " 

196 yield "" 

197 for name, line_str in self.matched_refused: 

198 yield format_line_report(Status.REFUSED.symbol, name, line_str) 

199 

200 def is_ok(self) -> bool: 

201 if self.unmatched_expectations: 

202 return False 

203 for line in self.content: 

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

205 return False 

206 return True 

207 

208 

209def format_line_report(symbol: str, cause: str, line: str) -> str: 

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

211 

212 

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

214 # Remove leading whitespace, ignore empty lines. 

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

216 

217 

218class Pattern: 

219 name: str 

220 library: PatternsLib 

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

222 inherited: set[str] 

223 

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

225 self.name = name 

226 self.library = library 

227 self.ops = [] 

228 self.inherited = set() 

229 

230 # Modifiers (Verbs) 

231 

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

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

234 self.inherited.update(base_patterns) 

235 

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

237 pass 

238 

239 # Matches (Adjectives) 

240 

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

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

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

244 

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

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

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

248 

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

250 """These lines are optional.""" 

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

252 

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

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

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

256 

257 # Internal API 

258 

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

260 for inherited_pattern in self.inherited: 

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

262 yield from self.ops 

263 

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

265 audit = Audit(content) 

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

267 getattr(audit, op)(*args) 

268 return audit 

269 

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

271 assert isinstance(other, str) 

272 audit = self._audit(other) 

273 return audit.is_ok() 

274 

275 

276class PatternsLib: 

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

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

279 return res