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
« prev ^ index » next coverage.py v7.3.2, created at 2024-10-18 23:38 +0200
1from __future__ import annotations
3import enum
4import re
5from typing import Any, Iterator, List, Set, Tuple
7import pytest
10@pytest.fixture
11def patterns() -> PatternsLib:
12 return PatternsLib()
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
28class Status(enum.Enum):
29 UNEXPECTED = 1
30 OPTIONAL = 2
31 EXPECTED = 3
32 REFUSED = 4
34 @property
35 def symbol(self) -> str:
36 return STATUS_SYMBOLS[self]
39STATUS_SYMBOLS = {
40 Status.UNEXPECTED: "🟡",
41 Status.EXPECTED: "🟢",
42 Status.OPTIONAL: "⚪️",
43 Status.REFUSED: "🔴",
44}
46EMPTY_LINE_PATTERN = "<empty-line>"
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
55def match(pattern: str, line: str) -> bool | re.Match[str] | None:
56 if pattern == EMPTY_LINE_PATTERN:
57 if not line:
58 return True
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)
67class Line:
68 status: Status = Status.UNEXPECTED
69 status_cause: str = ""
71 def __init__(self, data: str):
72 self.data = data
74 def matches(self, expectation: str) -> bool:
75 return bool(match(expectation, self.data))
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
85class Audit:
86 content: list[Line]
87 unmatched_expectations: list[tuple[str, str]]
88 matched_refused: set[tuple[str, str]]
90 def __init__(self, content: str):
91 self.unmatched_expectations = []
92 self.matched_refused = set()
94 self.content = []
95 for line in content.splitlines():
96 self.content.append(Line(line))
98 def cursor(self) -> Iterator[Line]:
99 return iter(self.content)
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()
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)
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))
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 )
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)
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
209def format_line_report(symbol: str, cause: str, line: str) -> str:
210 return symbol + " " + cause.ljust(15)[:15] + " | " + line
213def pattern_lines(lines: str) -> list[str]:
214 # Remove leading whitespace, ignore empty lines.
215 return list(filter(None, lines.splitlines()))
218class Pattern:
219 name: str
220 library: PatternsLib
221 ops: list[tuple[str, str, Any]]
222 inherited: set[str]
224 def __init__(self, library: PatternsLib, name: str):
225 self.name = name
226 self.library = library
227 self.ops = []
228 self.inherited = set()
230 # Modifiers (Verbs)
232 def merge(self, *base_patterns: str) -> None:
233 """Merge rules from base_patterns (recursively) into this pattern."""
234 self.inherited.update(base_patterns)
236 def normalize(self, mode: str) -> None:
237 pass
239 # Matches (Adjectives)
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)))
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)))
249 def optional(self, lines: str) -> None:
250 """These lines are optional."""
251 self.ops.append(("optional", self.name, pattern_lines(lines)))
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)))
257 # Internal API
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
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
270 def __eq__(self, other: object) -> bool:
271 assert isinstance(other, str)
272 audit = self._audit(other)
273 return audit.is_ok()
276class PatternsLib:
277 def __getattr__(self, name: str) -> Pattern:
278 res = self.__dict__[name] = Pattern(self, name)
279 return res