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
« 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
5import pytest
8@pytest.fixture
9def patterns():
10 yield PatternsLib()
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())
22class Status(enum.Enum):
23 UNEXPECTED = 1
24 OPTIONAL = 2
25 EXPECTED = 3
26 REFUSED = 4
28 @property
29 def symbol(self):
30 return STATUS_SYMBOLS[self]
33STATUS_SYMBOLS = {
34 Status.UNEXPECTED: "🟡",
35 Status.EXPECTED: "🟢",
36 Status.OPTIONAL: "⚪️",
37 Status.REFUSED: "🔴",
38}
40EMPTY_LINE_PATTERN = "<empty-line>"
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)
55class Line:
56 status: Status = Status.UNEXPECTED
57 status_cause: str = ""
59 def __init__(self, data: str):
60 self.data = data
62 def matches(self, expectation: str):
63 return bool(match(expectation, self.data))
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
73class Audit:
74 content: List[Line]
75 unmatched_expectations: List[Tuple[str, str]]
76 matched_refused: Set[Tuple[str, str]]
78 def __init__(self, content: str):
79 self.unmatched_expectations = []
80 self.matched_refused = set()
82 self.content = []
83 for line in content.splitlines():
84 self.content.append(Line(line))
86 def cursor(self):
87 return iter(self.content)
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()
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)
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))
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 )
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)
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
191def format_line_report(symbol, cause, line):
192 return symbol + " " + cause.ljust(15)[:15] + " | " + line
195def pattern_lines(lines: str) -> List[str]:
196 # Remove leading whitespace, ignore empty lines.
197 return list(filter(None, lines.splitlines()))
200class Pattern:
201 def __init__(self, library, name):
202 self.name = name
203 self.library = library
204 self.ops = []
205 self.inherited = set()
207 # Modifiers (Verbs)
209 def merge(self, *base_patterns):
210 """Merge the rules from those patterns (recursively) into this pattern."""
211 self.inherited.update(base_patterns)
213 def normalize(self, mode: str):
214 pass
216 # Matches (Adjectives)
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)))
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)))
226 def optional(self, lines: str):
227 """These lines are optional."""
228 self.ops.append(("optional", self.name, pattern_lines(lines)))
230 def refused(self, lines: str):
231 """If those lines appear they are refused."""
232 self.ops.append(("refused", self.name, pattern_lines(lines)))
234 # Internal API
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
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
247 def __eq__(self, other):
248 assert isinstance(other, str)
249 audit = self._audit(other)
250 return audit.is_ok()
253class PatternsLib:
254 def __getattr__(self, name):
255 self.__dict__[name] = Pattern(self, name)
256 return self.__dict__[name]