Python · 9718 bytes Raw Blame History
1 """
2 Output matching utilities for interactive tests.
3
4 Provides flexible matching for verifying shell output, supporting
5 exact matches, substrings, regex patterns, and structured output.
6 """
7
8 import re
9 from typing import Union, List, Optional
10 from dataclasses import dataclass
11
12
13 @dataclass
14 class MatchResult:
15 """Result of a match operation."""
16 matched: bool
17 message: str
18 actual: str
19 expected: str
20
21
22 def match_exact(actual: str, expected: str) -> MatchResult:
23 """
24 Match output exactly (after stripping whitespace).
25
26 Args:
27 actual: Actual output
28 expected: Expected output
29
30 Returns:
31 MatchResult indicating success/failure
32 """
33 actual_stripped = actual.strip()
34 expected_stripped = expected.strip()
35
36 if actual_stripped == expected_stripped:
37 return MatchResult(True, "Exact match", actual_stripped, expected_stripped)
38
39 return MatchResult(
40 False,
41 f"Exact match failed",
42 actual_stripped,
43 expected_stripped
44 )
45
46
47 def match_contains(actual: str, expected: str) -> MatchResult:
48 """
49 Check if output contains expected substring.
50
51 Args:
52 actual: Actual output
53 expected: Expected substring
54
55 Returns:
56 MatchResult indicating success/failure
57 """
58 if expected in actual:
59 return MatchResult(True, "Contains match", actual, expected)
60
61 return MatchResult(
62 False,
63 f"Substring not found",
64 actual,
65 expected
66 )
67
68
69 def match_regex(actual: str, pattern: str, flags: int = 0) -> MatchResult:
70 """
71 Match output against a regex pattern.
72
73 Args:
74 actual: Actual output
75 pattern: Regex pattern
76 flags: Regex flags (e.g., re.IGNORECASE)
77
78 Returns:
79 MatchResult indicating success/failure
80 """
81 try:
82 if re.search(pattern, actual, flags):
83 return MatchResult(True, "Regex match", actual, pattern)
84 return MatchResult(False, "Regex not matched", actual, pattern)
85 except re.error as e:
86 return MatchResult(False, f"Invalid regex: {e}", actual, pattern)
87
88
89 def match_lines(actual: str, expected_lines: List[str]) -> MatchResult:
90 """
91 Match output line by line.
92
93 Args:
94 actual: Actual output
95 expected_lines: List of expected lines
96
97 Returns:
98 MatchResult indicating success/failure
99 """
100 actual_lines = actual.strip().split('\n')
101
102 if len(actual_lines) != len(expected_lines):
103 return MatchResult(
104 False,
105 f"Line count mismatch: got {len(actual_lines)}, expected {len(expected_lines)}",
106 '\n'.join(actual_lines),
107 '\n'.join(expected_lines)
108 )
109
110 for i, (act, exp) in enumerate(zip(actual_lines, expected_lines)):
111 if act.strip() != exp.strip():
112 return MatchResult(
113 False,
114 f"Line {i+1} mismatch",
115 act.strip(),
116 exp.strip()
117 )
118
119 return MatchResult(
120 True,
121 "All lines match",
122 '\n'.join(actual_lines),
123 '\n'.join(expected_lines)
124 )
125
126
127 def match_startswith(actual: str, prefix: str) -> MatchResult:
128 """
129 Check if output starts with expected prefix.
130
131 Args:
132 actual: Actual output
133 prefix: Expected prefix
134
135 Returns:
136 MatchResult indicating success/failure
137 """
138 stripped = actual.strip()
139 if stripped.startswith(prefix):
140 return MatchResult(True, "Prefix match", stripped, prefix)
141
142 return MatchResult(
143 False,
144 "Prefix not found",
145 stripped[:len(prefix)+20] + "..." if len(stripped) > len(prefix)+20 else stripped,
146 prefix
147 )
148
149
150 def match_endswith(actual: str, suffix: str) -> MatchResult:
151 """
152 Check if output ends with expected suffix.
153
154 Args:
155 actual: Actual output
156 suffix: Expected suffix
157
158 Returns:
159 MatchResult indicating success/failure
160 """
161 stripped = actual.strip()
162 if stripped.endswith(suffix):
163 return MatchResult(True, "Suffix match", stripped, suffix)
164
165 return MatchResult(
166 False,
167 "Suffix not found",
168 "..." + stripped[-(len(suffix)+20):] if len(stripped) > len(suffix)+20 else stripped,
169 suffix
170 )
171
172
173 def match_not_contains(actual: str, unwanted: str) -> MatchResult:
174 """
175 Verify output does NOT contain a substring.
176
177 Args:
178 actual: Actual output
179 unwanted: Substring that should not be present
180
181 Returns:
182 MatchResult indicating success/failure
183 """
184 if unwanted not in actual:
185 return MatchResult(True, "Correctly absent", actual, f"NOT {unwanted}")
186
187 return MatchResult(
188 False,
189 f"Unwanted substring found",
190 actual,
191 f"NOT {unwanted}"
192 )
193
194
195 def match_empty(actual: str) -> MatchResult:
196 """
197 Verify output is empty (after stripping whitespace).
198
199 Args:
200 actual: Actual output
201
202 Returns:
203 MatchResult indicating success/failure
204 """
205 stripped = actual.strip()
206 if not stripped:
207 return MatchResult(True, "Output is empty", stripped, "<empty>")
208
209 return MatchResult(
210 False,
211 "Output is not empty",
212 stripped,
213 "<empty>"
214 )
215
216
217 def match_not_empty(actual: str) -> MatchResult:
218 """
219 Verify output is not empty.
220
221 Args:
222 actual: Actual output
223
224 Returns:
225 MatchResult indicating success/failure
226 """
227 stripped = actual.strip()
228 if stripped:
229 return MatchResult(True, "Output is not empty", stripped, "<non-empty>")
230
231 return MatchResult(
232 False,
233 "Output is unexpectedly empty",
234 stripped,
235 "<non-empty>"
236 )
237
238
239 class OutputMatcher:
240 """
241 Flexible output matcher supporting multiple match types.
242
243 Usage:
244 matcher = OutputMatcher()
245 result = matcher.match(output, expected="hello", match_type="contains")
246
247 # Or use the builder pattern:
248 result = matcher.contains("hello").match(output)
249 """
250
251 def __init__(self):
252 self._match_type = "exact"
253 self._expected = ""
254 self._flags = 0
255
256 def exact(self, expected: str) -> 'OutputMatcher':
257 """Set up exact match."""
258 self._match_type = "exact"
259 self._expected = expected
260 return self
261
262 def contains(self, substring: str) -> 'OutputMatcher':
263 """Set up contains match."""
264 self._match_type = "contains"
265 self._expected = substring
266 return self
267
268 def regex(self, pattern: str, flags: int = 0) -> 'OutputMatcher':
269 """Set up regex match."""
270 self._match_type = "regex"
271 self._expected = pattern
272 self._flags = flags
273 return self
274
275 def startswith(self, prefix: str) -> 'OutputMatcher':
276 """Set up prefix match."""
277 self._match_type = "startswith"
278 self._expected = prefix
279 return self
280
281 def endswith(self, suffix: str) -> 'OutputMatcher':
282 """Set up suffix match."""
283 self._match_type = "endswith"
284 self._expected = suffix
285 return self
286
287 def match(
288 self,
289 actual: str,
290 expected: Optional[str] = None,
291 match_type: Optional[str] = None
292 ) -> MatchResult:
293 """
294 Perform the match.
295
296 Args:
297 actual: Actual output to match
298 expected: Expected value (overrides builder)
299 match_type: Match type (overrides builder)
300
301 Returns:
302 MatchResult
303 """
304 exp = expected if expected is not None else self._expected
305 mt = match_type if match_type is not None else self._match_type
306
307 if mt == "exact":
308 return match_exact(actual, exp)
309 elif mt == "contains":
310 return match_contains(actual, exp)
311 elif mt == "regex":
312 return match_regex(actual, exp, self._flags)
313 elif mt == "startswith":
314 return match_startswith(actual, exp)
315 elif mt == "endswith":
316 return match_endswith(actual, exp)
317 elif mt == "empty":
318 return match_empty(actual)
319 elif mt == "not_empty":
320 return match_not_empty(actual)
321 elif mt == "not_contains":
322 return match_not_contains(actual, exp)
323 elif mt == "lines":
324 if isinstance(exp, str):
325 exp = exp.split('\n')
326 return match_lines(actual, exp)
327 else:
328 return MatchResult(False, f"Unknown match type: {mt}", actual, exp)
329
330
331 # Convenience functions for pytest-style assertions
332 def assert_output_equals(actual: str, expected: str, msg: str = "") -> None:
333 """Assert output equals expected (pytest-friendly)."""
334 result = match_exact(actual, expected)
335 if not result.matched:
336 raise AssertionError(
337 f"{msg}\n{result.message}\n"
338 f"Expected: {result.expected}\n"
339 f"Actual: {result.actual}"
340 )
341
342
343 def assert_output_contains(actual: str, substring: str, msg: str = "") -> None:
344 """Assert output contains substring (pytest-friendly)."""
345 result = match_contains(actual, substring)
346 if not result.matched:
347 raise AssertionError(
348 f"{msg}\n{result.message}\n"
349 f"Expected to contain: {result.expected}\n"
350 f"Actual: {result.actual}"
351 )
352
353
354 def assert_output_matches(actual: str, pattern: str, msg: str = "") -> None:
355 """Assert output matches regex pattern (pytest-friendly)."""
356 result = match_regex(actual, pattern)
357 if not result.matched:
358 raise AssertionError(
359 f"{msg}\n{result.message}\n"
360 f"Pattern: {result.expected}\n"
361 f"Actual: {result.actual}"
362 )