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

165 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-17 22:55 +0100

1import enum 

2import re 

3from typing import Iterable, List, Set, Tuple 

4 

5import pytest 

6 

7 

8@pytest.fixture 

9def patterns(): 

10 yield PatternsLib() 

11 

12 

13def pytest_assertrepr_compare(op, left, right): 

14 if op != "==": 

15 return 

16 if left.__class__.__name__ == "Pattern": 

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

18 elif right.__class__.__name__ == "Pattern": 

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

20 

21 

22class Status(enum.Enum): 

23 UNEXPECTED = 1 

24 OPTIONAL = 2 

25 EXPECTED = 3 

26 REFUSED = 4 

27 

28 @property 

29 def symbol(self): 

30 return STATUS_SYMBOLS[self] 

31 

32 

33STATUS_SYMBOLS = { 

34 Status.UNEXPECTED: "🟡", 

35 Status.EXPECTED: "🟢", 

36 Status.OPTIONAL: "⚪️", 

37 Status.REFUSED: "🔴", 

38} 

39 

40EMPTY_LINE_PATTERN = "<empty-line>" 

41 

42 

43def match(pattern, line): 

44 if pattern == EMPTY_LINE_PATTERN: 

45 if not line: 

46 return True 

47 pattern = pattern.replace("\t", " " * 8) 

48 line = line.replace("\t", " " * 8) 

49 pattern = re.escape(pattern) 

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

51 pattern = re.compile("^" + pattern + "$") 

52 return pattern.match(line) 

53 

54 

55class Line: 

56 status: Status = Status.UNEXPECTED 

57 status_cause: str = "" 

58 

59 def __init__(self, data: str): 

60 self.data = data 

61 

62 def matches(self, expectation: str): 

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

64 

65 def mark(self, status: Status, cause: str): 

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

67 # Stay in the current status 

68 return 

69 self.status = status 

70 self.status_cause = cause 

71 

72 

73class Audit: 

74 content: List[Line] 

75 unmatched_expectations: List[Tuple[str, str]] 

76 matched_refused: Set[Tuple[str, str]] 

77 

78 def __init__(self, content: str): 

79 self.unmatched_expectations = [] 

80 self.matched_refused = set() 

81 

82 self.content = [] 

83 for line in content.splitlines(): 

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

85 

86 def cursor(self): 

87 return iter(self.content) 

88 

89 def in_order(self, name: str, expected_lines: List[str]): 

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

91 may be interleaved with other lines.""" 

92 cursor = self.cursor() 

93 for expected_line in expected_lines: 

94 for line in cursor: 

95 if line.matches(expected_line): 

96 line.mark(Status.EXPECTED, name) 

97 break 

98 else: 

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

100 # Reset the scan, maybe the other lines will match 

101 cursor = self.cursor() 

102 

103 def optional(self, name: str, tolerated_lines: List[str]): 

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

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

106 """ 

107 for tolerated_line in tolerated_lines: 

108 for line in self.cursor(): 

109 if line.matches(tolerated_line): 

110 line.mark(Status.OPTIONAL, name) 

111 

112 def refused(self, name: str, refused_lines: List[str]): 

113 for refused_line in refused_lines: 

114 for line in self.cursor(): 

115 if line.matches(refused_line): 

116 line.mark(Status.REFUSED, name) 

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

118 

119 def continuous(self, name: str, continuous_lines: List[str]): 

120 continuous_cursor = enumerate(continuous_lines) 

121 continuous_index, continuous_line = next(continuous_cursor) 

122 for line in self.cursor(): 

123 if continuous_index and not line.data: 

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

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

126 # more readable. 

127 line.mark(Status.OPTIONAL, name) 

128 continue 

129 if line.matches(continuous_line): 

130 line.mark(Status.EXPECTED, name) 

131 try: 

132 continuous_index, continuous_line = next(continuous_cursor) 

133 except StopIteration: 

134 # We exhausted the pattern and are happy. 

135 break 

136 elif continuous_index: 

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

138 # not match 

139 line.mark(Status.REFUSED, name) 

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

141 self.unmatched_expectations.extend( 

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

143 ) 

144 break 

145 else: 

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

147 self.unmatched_expectations.extend( 

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

149 ) 

150 

151 def report(self): 

152 yield "String did not meet the expectations." 

153 yield "" 

154 yield " | ".join( 

155 [ 

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

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

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

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

160 ] 

161 ) 

162 yield "" 

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

164 yield "" 

165 for line in self.content: 

166 yield format_line_report( 

167 line.status.symbol, line.status_cause, line.data 

168 ) 

169 if self.unmatched_expectations: 

170 yield "" 

171 yield "These are the unmatched expected lines: " 

172 yield "" 

173 for name, line in self.unmatched_expectations: 

174 yield format_line_report(Status.REFUSED.symbol, name, line) 

175 if self.matched_refused: 

176 yield "" 

177 yield "These are the matched refused lines: " 

178 yield "" 

179 for name, line in self.matched_refused: 

180 yield format_line_report(Status.REFUSED.symbol, name, line) 

181 

182 def is_ok(self): 

183 if self.unmatched_expectations: 

184 return False 

185 for line in self.content: 

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

187 return False 

188 return True 

189 

190 

191def format_line_report(symbol, cause, line): 

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

193 

194 

195def pattern_lines(lines: str) -> List[str]: 

196 # Remove leading whitespace, ignore empty lines. 

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

198 

199 

200class Pattern: 

201 def __init__(self, library, name): 

202 self.name = name 

203 self.library = library 

204 self.ops = [] 

205 self.inherited = set() 

206 

207 # Modifiers (Verbs) 

208 

209 def merge(self, *base_patterns): 

210 """Merge the rules from those patterns (recursively) into this pattern.""" 

211 self.inherited.update(base_patterns) 

212 

213 def normalize(self, mode: str): 

214 pass 

215 

216 # Matches (Adjectives) 

217 

218 def continuous(self, lines: str): 

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

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

221 

222 def in_order(self, lines: str): 

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

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

225 

226 def optional(self, lines: str): 

227 """These lines are optional.""" 

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

229 

230 def refused(self, lines: str): 

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

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

233 

234 # Internal API 

235 

236 def flat_ops(self): 

237 for inherited_pattern in self.inherited: 

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

239 yield from self.ops 

240 

241 def _audit(self, content): 

242 audit = Audit(content) 

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

244 getattr(audit, op)(*args) 

245 return audit 

246 

247 def __eq__(self, other): 

248 assert isinstance(other, str) 

249 audit = self._audit(other) 

250 return audit.is_ok() 

251 

252 

253class PatternsLib: 

254 def __getattr__(self, name): 

255 self.__dict__[name] = Pattern(self, name) 

256 return self.__dict__[name]