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
« prev ^ index » next coverage.py v7.3.2, created at 2024-10-22 17:59 +0200
1from __future__ import annotations
3import enum
4import re
5from typing import Any, Iterator
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>"
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
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}
94def to_control_picture(char: str) -> str:
95 return ascii_to_control_pictures.get(ord(char), char)
98def line_to_control_pictures(line: str) -> str:
99 return "".join(to_control_picture(char) for char in line)
102def match(pattern: str, line: str) -> bool | re.Match[str] | None:
103 if pattern == EMPTY_LINE_PATTERN:
104 if not line:
105 return True
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)
114class Line:
115 status: Status = Status.UNEXPECTED
116 status_cause: str = ""
118 def __init__(self, data: str):
119 self.data = data
121 def matches(self, expectation: str) -> bool:
122 return bool(match(expectation, self.data))
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
132class Audit:
133 content: list[Line]
134 unmatched_expectations: list[tuple[str, str]]
135 matched_refused: set[tuple[str, str]]
137 def __init__(self, content: str):
138 self.unmatched_expectations = []
139 self.matched_refused = set()
141 self.content = []
142 for line in content.splitlines():
143 self.content.append(Line(line))
145 def cursor(self) -> Iterator[Line]:
146 return iter(self.content)
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()
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)
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))
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 )
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 )
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
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
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
279def pattern_lines(lines: str) -> list[str]:
280 # Remove leading whitespace, ignore empty lines.
281 return list(filter(None, lines.splitlines()))
284class Pattern:
285 name: str
286 library: PatternsLib
287 ops: list[tuple[str, str, Any]]
288 inherited: set[str]
290 def __init__(self, library: PatternsLib, name: str):
291 self.name = name
292 self.library = library
293 self.ops = []
294 self.inherited = set()
296 # Modifiers (Verbs)
298 def merge(self, *base_patterns: str) -> None:
299 """Merge rules from base_patterns (recursively) into this pattern."""
300 self.inherited.update(base_patterns)
302 def normalize(self, mode: str) -> None:
303 pass
305 # Matches (Adjectives)
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)))
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)))
315 def optional(self, lines: str) -> None:
316 """These lines are optional."""
317 self.ops.append(("optional", self.name, pattern_lines(lines)))
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)))
323 # Internal API
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
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
336 def __eq__(self, other: object) -> bool:
337 assert isinstance(other, str)
338 audit = self._audit(other)
339 return audit.is_ok()
342class PatternsLib:
343 def __getattr__(self, name: str) -> Pattern:
344 res = self.__dict__[name] = Pattern(self, name)
345 return res