codetool-shell 0.1.1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. codetool_shell/__init__.py +11 -0
  2. codetool_shell/api.py +59 -0
  3. codetool_shell/bin/windows-x86_64/codetool-shell-rust.exe +0 -0
  4. codetool_shell/filters/__init__.py +14 -0
  5. codetool_shell/filters/build_compiler/__init__.py +7 -0
  6. codetool_shell/filters/build_compiler/detector.py +412 -0
  7. codetool_shell/filters/build_compiler/reducer.py +166 -0
  8. codetool_shell/filters/build_compiler/summary.py +617 -0
  9. codetool_shell/filters/ci_job_log/__init__.py +7 -0
  10. codetool_shell/filters/ci_job_log/detector.py +64 -0
  11. codetool_shell/filters/ci_job_log/reducer.py +99 -0
  12. codetool_shell/filters/ci_job_log/summary.py +243 -0
  13. codetool_shell/filters/diff/__init__.py +7 -0
  14. codetool_shell/filters/diff/detector.py +136 -0
  15. codetool_shell/filters/diff/reducer.py +308 -0
  16. codetool_shell/filters/generic_log/__init__.py +7 -0
  17. codetool_shell/filters/generic_log/detector.py +175 -0
  18. codetool_shell/filters/generic_log/reducer.py +99 -0
  19. codetool_shell/filters/generic_log/summary.py +161 -0
  20. codetool_shell/filters/git.py +514 -0
  21. codetool_shell/filters/html_cleanup/__init__.py +7 -0
  22. codetool_shell/filters/html_cleanup/detector.py +136 -0
  23. codetool_shell/filters/html_cleanup/reducer.py +27 -0
  24. codetool_shell/filters/html_cleanup/summary.py +422 -0
  25. codetool_shell/filters/json_payload/__init__.py +7 -0
  26. codetool_shell/filters/json_payload/detector.py +62 -0
  27. codetool_shell/filters/json_payload/reducer.py +81 -0
  28. codetool_shell/filters/json_payload/summary.py +233 -0
  29. codetool_shell/filters/listing/__init__.py +7 -0
  30. codetool_shell/filters/listing/detector.py +294 -0
  31. codetool_shell/filters/listing/reducer.py +30 -0
  32. codetool_shell/filters/log_template/__init__.py +7 -0
  33. codetool_shell/filters/log_template/constants.py +76 -0
  34. codetool_shell/filters/log_template/detector.py +331 -0
  35. codetool_shell/filters/log_template/reducer.py +78 -0
  36. codetool_shell/filters/log_template/template.py +280 -0
  37. codetool_shell/filters/log_template/types.py +21 -0
  38. codetool_shell/filters/opaque_payload/__init__.py +7 -0
  39. codetool_shell/filters/opaque_payload/detector.py +563 -0
  40. codetool_shell/filters/opaque_payload/reducer.py +142 -0
  41. codetool_shell/filters/opaque_payload/summary.py +61 -0
  42. codetool_shell/filters/package_manager/__init__.py +7 -0
  43. codetool_shell/filters/package_manager/detector.py +220 -0
  44. codetool_shell/filters/package_manager/reducer.py +110 -0
  45. codetool_shell/filters/package_manager/summary.py +172 -0
  46. codetool_shell/filters/pipeline.py +65 -0
  47. codetool_shell/filters/rg.py +250 -0
  48. codetool_shell/filters/system_output/__init__.py +7 -0
  49. codetool_shell/filters/system_output/detector.py +600 -0
  50. codetool_shell/filters/system_output/reducer.py +331 -0
  51. codetool_shell/filters/system_output/summary.py +164 -0
  52. codetool_shell/filters/table/__init__.py +7 -0
  53. codetool_shell/filters/table/detector.py +244 -0
  54. codetool_shell/filters/table/reducer.py +57 -0
  55. codetool_shell/filters/table/summary.py +37 -0
  56. codetool_shell/filters/test_runner/__init__.py +7 -0
  57. codetool_shell/filters/test_runner/ansi.py +80 -0
  58. codetool_shell/filters/test_runner/detector.py +409 -0
  59. codetool_shell/filters/test_runner/reducer.py +288 -0
  60. codetool_shell/filters/test_runner/summary.py +449 -0
  61. codetool_shell/filters/text.py +38 -0
  62. codetool_shell/filters/traceback/__init__.py +7 -0
  63. codetool_shell/filters/traceback/detector.py +209 -0
  64. codetool_shell/filters/traceback/reducer.py +141 -0
  65. codetool_shell/filters/traceback/summary.py +122 -0
  66. codetool_shell/filters/tree.py +59 -0
  67. codetool_shell/py.typed +0 -0
  68. codetool_shell/python_backend.py +38 -0
  69. codetool_shell/rust_backend.py +254 -0
  70. codetool_shell-0.1.1.dist-info/METADATA +152 -0
  71. codetool_shell-0.1.1.dist-info/RECORD +72 -0
  72. codetool_shell-0.1.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,449 @@
1
+ """Line classification for test-runner summaries and diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ def is_summary_line(line: str) -> bool:
9
+ """Return true for compact test-runner summary/timing lines."""
10
+
11
+ stripped = line.strip()
12
+ return (
13
+ _is_pytest_terminal_summary(stripped)
14
+ or _is_pytest_quiet_summary(stripped)
15
+ or stripped.startswith("collected ")
16
+ or _is_cargo_running_line(stripped)
17
+ or _is_go_summary_line(stripped)
18
+ or _is_unittest_ran_line(stripped)
19
+ or _is_unittest_terminal_outcome_line(stripped)
20
+ or is_playwright_run_line(stripped)
21
+ or is_playwright_summary_line(stripped)
22
+ or _is_jest_summary_line(stripped)
23
+ or _is_maven_surefire_summary_line(stripped)
24
+ or _is_gradle_test_summary_line(stripped)
25
+ or _is_dotnet_test_summary_line(stripped)
26
+ or _is_rspec_summary_line(stripped)
27
+ or _is_minitest_summary_line(stripped)
28
+ or stripped.startswith(("Test Files", "Tests", "Duration", "Start at"))
29
+ or stripped.startswith("test result:")
30
+ )
31
+
32
+
33
+ def is_run_marker_line(line: str) -> bool:
34
+ """Return true for a concise framework run marker."""
35
+
36
+ stripped = line.lstrip()
37
+ return stripped.startswith("RUN ") or stripped.startswith("=== RUN ")
38
+
39
+
40
+ def is_failure_detail_line(line: str) -> bool:
41
+ """Return true for failure identifiers, assertions, and source locations."""
42
+
43
+ stripped = line.strip()
44
+ pytest_heading = stripped.strip("_").strip()
45
+ location = stripped.removeprefix("❯").strip()
46
+ if _is_playwright_progress_line(stripped):
47
+ return False
48
+ return (
49
+ stripped.startswith(
50
+ (
51
+ "FAILED ",
52
+ "FAIL ",
53
+ "FAIL:",
54
+ "FAIL",
55
+ "ERROR ",
56
+ "ERROR:",
57
+ "AssertionError",
58
+ "Error:",
59
+ "ImportError",
60
+ "Traceback",
61
+ "thread '",
62
+ "failures:",
63
+ "--- FAIL:",
64
+ "--- SKIP:",
65
+ "Failed!",
66
+ "Failed tests:",
67
+ "Failures:",
68
+ "Pending:",
69
+ "Test Failed.",
70
+ "Expected:",
71
+ "Received:",
72
+ "Actual:",
73
+ "Failure/Error:",
74
+ "Message:",
75
+ "Messages:",
76
+ "Stacktrace:",
77
+ "[ERROR]",
78
+ )
79
+ )
80
+ or " FAILED" in stripped
81
+ or pytest_heading.startswith("ERROR ")
82
+ or _is_unittest_verbose_failure_line(stripped)
83
+ or _is_python_frame_line(stripped)
84
+ or _is_python_exception_line(stripped)
85
+ or _is_java_exception_line(stripped)
86
+ or _is_dotnet_failure_line(stripped)
87
+ or _is_rspec_failure_line(stripped)
88
+ or _is_minitest_failure_line(stripped)
89
+ or "test report" in stripped.lower()
90
+ or "report at:" in stripped.lower()
91
+ or _is_assert_or_raise_source_line(stripped)
92
+ or _is_playwright_failure_detail_line(stripped)
93
+ or _looks_like_path_location(location)
94
+ or line.startswith(("E ", "E ", "> ", " left:", " right:"))
95
+ or stripped.startswith(("left:", "right:"))
96
+ or (stripped.startswith("test ") and " ... FAILED" in stripped)
97
+ )
98
+
99
+
100
+ def normalize_selected_line(line: str) -> str:
101
+ """Normalize selected lines for compact output."""
102
+
103
+ stripped = line.strip()
104
+ if _is_playwright_decorated_line(stripped):
105
+ return stripped.rstrip("─").rstrip()
106
+ return stripped
107
+
108
+
109
+ def is_unittest_progress_line(line: str) -> bool:
110
+ """Return true for compact unittest progress-marker lines."""
111
+
112
+ stripped = line.strip()
113
+ return len(stripped) >= 20 and all(
114
+ char in _UNITTEST_PROGRESS_CHARS for char in stripped
115
+ )
116
+
117
+
118
+ def collapse_unittest_progress_line(line: str) -> str:
119
+ """Summarize a long unittest progress-marker line."""
120
+
121
+ stripped = line.strip()
122
+ counts = [
123
+ _count_part(stripped.count("."), "passed"),
124
+ _count_part(stripped.count("s") + stripped.count("S"), "skipped"),
125
+ _count_part(
126
+ stripped.count("x") + stripped.count("X"),
127
+ "expected failures",
128
+ ),
129
+ _count_part(stripped.count("u") + stripped.count("U"), "unexpected successes"),
130
+ _count_part(stripped.count("F"), "failed"),
131
+ _count_part(stripped.count("E"), "errors"),
132
+ ]
133
+ details = ", ".join(part for part in counts if part)
134
+ if not details:
135
+ details = "markers only"
136
+ return f"progress: {len(stripped)} unittest markers ({details})"
137
+
138
+
139
+ def is_playwright_run_line(line: str) -> bool:
140
+ """Return true for the Playwright run-count marker."""
141
+
142
+ return _PLAYWRIGHT_RUN_RE.match(line.strip()) is not None
143
+
144
+
145
+ def is_playwright_summary_line(line: str) -> bool:
146
+ """Return true for strict Playwright final total lines."""
147
+
148
+ return _PLAYWRIGHT_SUMMARY_RE.match(line.strip()) is not None
149
+
150
+
151
+ def extract_playwright_project(line: str) -> str | None:
152
+ """Return a visible Playwright project name from a spec line."""
153
+
154
+ match = _PLAYWRIGHT_PROJECT_RE.search(line)
155
+ if match is None:
156
+ return None
157
+ project = match.group(1).strip()
158
+ return project or None
159
+
160
+
161
+ def is_playwright_attention_line(line: str) -> bool:
162
+ """Return true for non-failing Playwright retry/flaky identifiers."""
163
+
164
+ stripped = line.strip()
165
+ return "retry #" in stripped.lower() or _is_playwright_final_spec_line(stripped)
166
+
167
+
168
+ def _is_pytest_terminal_summary(line: str) -> bool:
169
+ return bool(
170
+ line.startswith("=")
171
+ and " in " in line
172
+ and any(
173
+ token in line
174
+ for token in (" passed", " failed", " error", " skipped", " xfailed", " xpassed")
175
+ )
176
+ )
177
+
178
+
179
+ def _is_pytest_quiet_summary(line: str) -> bool:
180
+ return _PYTEST_QUIET_SUMMARY_RE.match(line) is not None
181
+
182
+
183
+ def _is_cargo_running_line(line: str) -> bool:
184
+ return re.match(r"^running \d+ tests?$", line) is not None
185
+
186
+
187
+ def _is_unittest_ran_line(line: str) -> bool:
188
+ return _UNITTEST_RAN_RE.match(line) is not None
189
+
190
+
191
+ def _is_unittest_terminal_outcome_line(line: str) -> bool:
192
+ return line == "OK" or line.startswith(("OK (", "FAILED ("))
193
+
194
+
195
+ def _is_unittest_verbose_failure_line(line: str) -> bool:
196
+ return bool(
197
+ _UNITTEST_VERBOSE_RESULT_RE.match(line)
198
+ and any(line.endswith(f" ... {result}") for result in ("FAIL", "ERROR"))
199
+ )
200
+
201
+
202
+ def _is_python_frame_line(line: str) -> bool:
203
+ return line.startswith('File "') and '", line ' in line
204
+
205
+
206
+ def _is_python_exception_line(line: str) -> bool:
207
+ match = _PYTHON_EXCEPTION_RE.match(line)
208
+ if match is None:
209
+ return False
210
+ exception_name = match.group(1)
211
+ return exception_name.endswith(("Error", "Exception", "Warning"))
212
+
213
+
214
+ def _is_java_exception_line(line: str) -> bool:
215
+ return bool(_JAVA_EXCEPTION_RE.match(line) or line.startswith("Caused by: "))
216
+
217
+
218
+ def _is_dotnet_failure_line(line: str) -> bool:
219
+ return (
220
+ line.startswith(("Failed ", "Error Message:", "Stack Trace:", "at "))
221
+ or _DOTNET_FAILED_TEST_RE.match(line) is not None
222
+ )
223
+
224
+
225
+ def _is_rspec_failure_line(line: str) -> bool:
226
+ return (
227
+ _RSPEC_FAILURE_NUMBER_RE.match(line) is not None
228
+ or line.startswith(("# ./", "# /", "rspec "))
229
+ or line.startswith(("Failure/Error:", "expected", "got:"))
230
+ )
231
+
232
+
233
+ def is_jest_source_context_line(line: str) -> bool:
234
+ return _JEST_SOURCE_CONTEXT_RE.match(line.strip()) is not None
235
+
236
+
237
+ def _is_minitest_failure_line(line: str) -> bool:
238
+ return (
239
+ _MINITEST_FAILURE_HEADER_RE.match(line) is not None
240
+ or ("[" in line and ".rb:" in line and line.endswith("]:"))
241
+ or line.startswith(("Expected", "Actual", "Failure:", "Error:"))
242
+ )
243
+
244
+
245
+ def _is_assert_or_raise_source_line(line: str) -> bool:
246
+ return line.startswith(("assert ", "self.assert", "raise "))
247
+
248
+
249
+ def _is_playwright_failure_detail_line(line: str) -> bool:
250
+ return (
251
+ _PLAYWRIGHT_NUMBERED_FAILURE_RE.match(line) is not None
252
+ or _is_playwright_final_spec_line(line)
253
+ or line.startswith(
254
+ (
255
+ "Test timeout",
256
+ "TimeoutError:",
257
+ "Locator:",
258
+ "Expected:",
259
+ "Received:",
260
+ "Timeout:",
261
+ "Call log:",
262
+ "attachment #",
263
+ "Error Context:",
264
+ "Usage:",
265
+ "npx playwright show-trace",
266
+ "locator.",
267
+ "page.",
268
+ "expect.",
269
+ )
270
+ )
271
+ or _is_playwright_action_line(line)
272
+ or _is_playwright_source_focus_line(line)
273
+ or _looks_like_artifact_path(line)
274
+ )
275
+
276
+
277
+ def _is_playwright_decorated_line(line: str) -> bool:
278
+ return "─" in line and (
279
+ _PLAYWRIGHT_NUMBERED_FAILURE_RE.match(line) is not None
280
+ or line.startswith("attachment #")
281
+ )
282
+
283
+
284
+ def _is_playwright_final_spec_line(line: str) -> bool:
285
+ return line.startswith("[") and "] ›" in line
286
+
287
+
288
+ def _is_playwright_progress_line(line: str) -> bool:
289
+ return line.startswith(("✓", "✘", "×", "-")) and "] ›" in line
290
+
291
+
292
+ def _is_playwright_action_line(line: str) -> bool:
293
+ return line.startswith("- ") and any(
294
+ token in line
295
+ for token in (
296
+ "expect",
297
+ "waiting for",
298
+ "locator",
299
+ "page.",
300
+ "click",
301
+ "fill",
302
+ "press",
303
+ "goto",
304
+ )
305
+ )
306
+
307
+
308
+ def _is_playwright_source_focus_line(line: str) -> bool:
309
+ return line.startswith(">") and "|" in line
310
+
311
+
312
+ def _looks_like_artifact_path(line: str) -> bool:
313
+ return (
314
+ "/" in line
315
+ and not line.startswith("- ")
316
+ and not line.startswith(("http://", "https://"))
317
+ and line.endswith((".png", ".jpg", ".jpeg", ".webm", ".zip", ".html"))
318
+ and (
319
+ "test-results/" in line
320
+ or "playwright-report/" in line
321
+ or "blob-report/" in line
322
+ or "trace" in line
323
+ or "screenshot" in line
324
+ or "video" in line
325
+ )
326
+ )
327
+
328
+
329
+ def _count_part(count: int, label: str) -> str:
330
+ if count == 0:
331
+ return ""
332
+ return f"{count} {label}"
333
+
334
+
335
+ def _looks_like_path_location(line: str) -> bool:
336
+ parts = line.split(":")
337
+ if len(parts) < 2:
338
+ return False
339
+ for index in range(1, len(parts)):
340
+ if parts[index].isdigit():
341
+ path = ":".join(parts[:index])
342
+ return bool(path) and _looks_like_path(path)
343
+ return False
344
+
345
+
346
+ def _looks_like_path(path: str) -> bool:
347
+ if not path or path[0].isspace() or "://" in path:
348
+ return False
349
+ return "/" in path or "\\" in path or "." in path
350
+
351
+
352
+ def _is_go_summary_line(line: str) -> bool:
353
+ return (
354
+ _GO_TEST_RESULT_RE.match(line) is not None
355
+ or _GO_PACKAGE_RESULT_RE.match(line) is not None
356
+ or line in {"PASS", "FAIL"}
357
+ )
358
+
359
+
360
+ def _is_jest_summary_line(line: str) -> bool:
361
+ return (
362
+ line.startswith(("PASS ", "FAIL ", "Test Suites:", "Tests:", "Snapshots:", "Time:"))
363
+ or _JEST_FAILED_TEST_RE.match(line) is not None
364
+ )
365
+
366
+
367
+ def _is_maven_surefire_summary_line(line: str) -> bool:
368
+ return (
369
+ _MAVEN_SUREFIRE_SUMMARY_RE.match(line) is not None
370
+ or line.startswith("Results:")
371
+ or "surefire-reports" in line
372
+ )
373
+
374
+
375
+ def _is_gradle_test_summary_line(line: str) -> bool:
376
+ return (
377
+ _GRADLE_TEST_SUMMARY_RE.match(line) is not None
378
+ or (line.startswith("> Task ") and "FAILED" in line)
379
+ or "test report" in line.lower()
380
+ )
381
+
382
+
383
+ def _is_dotnet_test_summary_line(line: str) -> bool:
384
+ return (
385
+ line.startswith(("Passed!", "Failed!", "Total tests:", "Test Run "))
386
+ or _DOTNET_TEST_COUNT_RE.match(line) is not None
387
+ )
388
+
389
+
390
+ def _is_rspec_summary_line(line: str) -> bool:
391
+ return (
392
+ _RSPEC_SUMMARY_RE.match(line) is not None
393
+ or line.startswith(("Finished in ", "Failures:", "Pending:"))
394
+ )
395
+
396
+
397
+ def _is_minitest_summary_line(line: str) -> bool:
398
+ return _MINITEST_SUMMARY_RE.match(line) is not None
399
+
400
+
401
+ _UNITTEST_RAN_RE = re.compile(r"^Ran \d+ tests? in [0-9.]+s$")
402
+ _PYTEST_QUIET_SUMMARY_RE = re.compile(
403
+ r"^(?:\d+ (?:passed|failed|errors?|skipped|xfailed|xpassed|warnings?)"
404
+ r"(?:, \d+ (?:passed|failed|errors?|skipped|xfailed|xpassed|warnings?))*"
405
+ r"|no tests ran) in [0-9.]+s$"
406
+ )
407
+ _UNITTEST_VERBOSE_RESULT_RE = re.compile(
408
+ r"^[A-Za-z_][\w.<>-]*\s+\(.+\)\s+\.\.\.\s+"
409
+ r"(?:ok|FAIL|ERROR|skipped|expected failure|unexpected success)(?:\s+.*)?$"
410
+ )
411
+ _UNITTEST_PROGRESS_CHARS = frozenset(".FEsxXuUS")
412
+ _PYTHON_EXCEPTION_RE = re.compile(r"^((?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*):")
413
+ _JAVA_EXCEPTION_RE = re.compile(r"^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z_$][\w$]*(?:Exception|Error):")
414
+ _PLAYWRIGHT_RUN_RE = re.compile(r"^Running \d+ tests? using \d+ workers?$")
415
+ _PLAYWRIGHT_SUMMARY_RE = re.compile(
416
+ r"^\d+ (?:passed|failed|flaky|skipped|timed out|did not run|interrupted)"
417
+ r"(?: \([^)]*\))?$"
418
+ )
419
+ _PLAYWRIGHT_PROJECT_RE = re.compile(r"\[([^\]]+)\]\s+›")
420
+ _PLAYWRIGHT_NUMBERED_FAILURE_RE = re.compile(r"^\d+\) .+›")
421
+ _GO_TEST_RESULT_RE = re.compile(r"^--- (?:PASS|FAIL|SKIP):\s+\S+\s+\([0-9.]+s\)$")
422
+ _GO_PACKAGE_RESULT_RE = re.compile(
423
+ r"^(?:ok|FAIL|\?)\s+\S+(?:\s+[0-9.]+s|\s+\[no test files\])$"
424
+ )
425
+ _JEST_FAILED_TEST_RE = re.compile(r"^[✕●]\s+")
426
+ _JEST_SOURCE_CONTEXT_RE = re.compile(r"^(?:>?\s*)?\d+\s+\|.*|^\|\s*\^+")
427
+ _MAVEN_SUREFIRE_SUMMARY_RE = re.compile(
428
+ r"^(?:\[ERROR\]\s+)?Tests run:\s+\d+,\s+Failures:\s+\d+,\s+Errors:\s+\d+,\s+Skipped:\s+\d+",
429
+ re.IGNORECASE,
430
+ )
431
+ _GRADLE_TEST_SUMMARY_RE = re.compile(
432
+ r"^\d+\s+tests? completed(?:,\s+\d+\s+failed)?(?:,\s+\d+\s+skipped)?$",
433
+ re.IGNORECASE,
434
+ )
435
+ _DOTNET_TEST_COUNT_RE = re.compile(
436
+ r"^(?:Passed|Failed|Skipped):\s+\d+",
437
+ re.IGNORECASE,
438
+ )
439
+ _DOTNET_FAILED_TEST_RE = re.compile(r"^Failed\s+\S")
440
+ _RSPEC_SUMMARY_RE = re.compile(
441
+ r"^\d+\s+examples?,\s+\d+\s+failures?(?:,\s+\d+\s+pending)?$",
442
+ re.IGNORECASE,
443
+ )
444
+ _RSPEC_FAILURE_NUMBER_RE = re.compile(r"^\d+\)\s+")
445
+ _MINITEST_SUMMARY_RE = re.compile(
446
+ r"^\d+\s+runs?,\s+\d+\s+assertions?,\s+\d+\s+failures?,\s+\d+\s+errors?",
447
+ re.IGNORECASE,
448
+ )
449
+ _MINITEST_FAILURE_HEADER_RE = re.compile(r"^\d+\)\s+(?:Failure|Error):")
@@ -0,0 +1,38 @@
1
+ """Small text helpers shared by output filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def score(text: str) -> tuple[int, int]:
7
+ """Return a cheap size proxy used to keep only smaller filter output."""
8
+
9
+ return (len(text.encode("utf-8")), len(text))
10
+
11
+
12
+ def split_preserving_final_newline(text: str) -> tuple[list[str], bool]:
13
+ final_newline = text.endswith("\n")
14
+ body = text[:-1] if final_newline else text
15
+ if body == "":
16
+ return [], final_newline
17
+ return body.split("\n"), final_newline
18
+
19
+
20
+ def join_preserving_final_newline(lines: list[str], final_newline: bool) -> str:
21
+ output = "\n".join(lines)
22
+ if final_newline:
23
+ output += "\n"
24
+ return output
25
+
26
+
27
+ def choose_smaller_lines(original: list[str], candidate: list[str]) -> list[str]:
28
+ original_text = "\n".join(original)
29
+ candidate_text = "\n".join(candidate)
30
+ if score(candidate_text) < score(original_text):
31
+ return candidate
32
+ return original
33
+
34
+
35
+ def indent_lines(text: str) -> list[str]:
36
+ if not text:
37
+ return []
38
+ return [f" {line}" if line else "" for line in text.splitlines()]
@@ -0,0 +1,7 @@
1
+ """Standalone Python traceback shellion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .reducer import compress_traceback_output
6
+
7
+ __all__ = ["compress_traceback_output"]
@@ -0,0 +1,209 @@
1
+ """Conservative parser for standalone Python tracebacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from .summary import (
9
+ is_chain_separator,
10
+ is_context_line,
11
+ is_traceback_header,
12
+ is_unindented_exception_summary,
13
+ normalize_context_line,
14
+ normalize_selected_line,
15
+ parse_frame_line,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TracebackFrame:
21
+ """A parsed traceback frame with optional source/caret context."""
22
+
23
+ line: str
24
+ path: str
25
+ context: tuple[str, ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TracebackBlock:
30
+ """One Python traceback block ending in an exception summary."""
31
+
32
+ start: int
33
+ end: int
34
+ frames: tuple[TracebackFrame, ...]
35
+ exception: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TracebackSeparator:
40
+ """A chained-exception separator line."""
41
+
42
+ start: int
43
+ end: int
44
+ line: str
45
+
46
+
47
+ TracebackEvent = TracebackBlock | TracebackSeparator
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class TracebackParse:
52
+ """Parsed traceback events in source order."""
53
+
54
+ events: tuple[TracebackEvent, ...]
55
+
56
+
57
+ def parse_python_traceback(lines: list[str]) -> TracebackParse | None:
58
+ """Return parsed standalone traceback events, or ``None`` if uncertain."""
59
+
60
+ if _has_other_filter_ownership(lines):
61
+ return None
62
+
63
+ events: list[TracebackEvent] = []
64
+ invalid_header = False
65
+ index = 0
66
+ while index < len(lines):
67
+ stripped = lines[index].strip()
68
+ if is_traceback_header(stripped):
69
+ parsed = _parse_block(lines, index)
70
+ if parsed is None:
71
+ invalid_header = True
72
+ break
73
+ events.append(parsed)
74
+ index = parsed.end
75
+ continue
76
+ if is_chain_separator(stripped):
77
+ events.append(
78
+ TracebackSeparator(
79
+ start=index,
80
+ end=index + 1,
81
+ line=normalize_selected_line(lines[index]),
82
+ )
83
+ )
84
+ index += 1
85
+
86
+ blocks = [event for event in events if isinstance(event, TracebackBlock)]
87
+ if invalid_header or not blocks:
88
+ return None
89
+ return TracebackParse(tuple(_drop_unanchored_separators(events)))
90
+
91
+
92
+ def _parse_block(lines: list[str], start: int) -> TracebackBlock | None:
93
+ frames: list[TracebackFrame] = []
94
+ index = start + 1
95
+
96
+ while index < len(lines):
97
+ stripped = lines[index].strip()
98
+ if not stripped:
99
+ index += 1
100
+ continue
101
+ if is_traceback_header(stripped) or is_chain_separator(stripped):
102
+ break
103
+
104
+ parsed_frame = parse_frame_line(lines[index])
105
+ if parsed_frame is None:
106
+ if frames and is_unindented_exception_summary(lines[index]):
107
+ return TracebackBlock(
108
+ start=start,
109
+ end=index + 1,
110
+ frames=tuple(frames),
111
+ exception=normalize_selected_line(lines[index]),
112
+ )
113
+ return None
114
+
115
+ index += 1
116
+ context: list[str] = []
117
+ while index < len(lines):
118
+ inner = lines[index].strip()
119
+ if not inner:
120
+ index += 1
121
+ continue
122
+ if (
123
+ is_traceback_header(inner)
124
+ or is_chain_separator(inner)
125
+ or parse_frame_line(lines[index]) is not None
126
+ ):
127
+ break
128
+ if is_unindented_exception_summary(lines[index]):
129
+ frames.append(
130
+ TracebackFrame(
131
+ line=parsed_frame.line,
132
+ path=parsed_frame.path,
133
+ context=tuple(context),
134
+ )
135
+ )
136
+ return TracebackBlock(
137
+ start=start,
138
+ end=index + 1,
139
+ frames=tuple(frames),
140
+ exception=normalize_selected_line(lines[index]),
141
+ )
142
+ if is_context_line(lines[index]):
143
+ context.append(normalize_context_line(lines[index]))
144
+ index += 1
145
+ continue
146
+ return None
147
+
148
+ frames.append(
149
+ TracebackFrame(
150
+ line=parsed_frame.line,
151
+ path=parsed_frame.path,
152
+ context=tuple(context),
153
+ )
154
+ )
155
+
156
+ return None
157
+
158
+
159
+ def _drop_unanchored_separators(events: list[TracebackEvent]) -> list[TracebackEvent]:
160
+ output: list[TracebackEvent] = []
161
+ for position, event in enumerate(events):
162
+ if isinstance(event, TracebackBlock):
163
+ output.append(event)
164
+ continue
165
+ has_block_before = any(isinstance(item, TracebackBlock) for item in events[:position])
166
+ has_block_after = any(isinstance(item, TracebackBlock) for item in events[position + 1 :])
167
+ if has_block_before and has_block_after:
168
+ output.append(event)
169
+ return output
170
+
171
+
172
+ def _has_other_filter_ownership(lines: list[str]) -> bool:
173
+ stripped = [line.strip() for line in lines]
174
+ lowered_text = "\n".join(stripped).lower()
175
+
176
+ if any(line.startswith(("test output:", "ci log:")) for line in stripped):
177
+ return True
178
+ if "test session starts" in lowered_text or "short test summary info" in lowered_text:
179
+ return True
180
+ if _has_unittest_shape(stripped):
181
+ return True
182
+ if any(_has_ci_marker(line) for line in lines):
183
+ return True
184
+ return False
185
+
186
+
187
+ def _has_unittest_shape(lines: list[str]) -> bool:
188
+ return (
189
+ any(_UNITTEST_RAN_RE.match(line) is not None for line in lines)
190
+ and any(line == "OK" or line.startswith(("OK (", "FAILED (")) for line in lines)
191
+ and any(
192
+ line.startswith(("FAIL:", "ERROR:"))
193
+ or (len(line) >= 5 and set(line) == {"-"})
194
+ for line in lines
195
+ )
196
+ )
197
+
198
+
199
+ def _has_ci_marker(line: str) -> bool:
200
+ stripped = line.strip()
201
+ return (
202
+ "##[" in stripped
203
+ or stripped.startswith(("::error", "::warning", "::group::", "::endgroup::"))
204
+ or bool(_GHA_TIMESTAMP_PREFIX_RE.match(line))
205
+ )
206
+
207
+
208
+ _UNITTEST_RAN_RE = re.compile(r"^Ran \d+ tests? in [0-9.]+s$")
209
+ _GHA_TIMESTAMP_PREFIX_RE = re.compile(r"^(?:[^\t]+\t[^\t]+\t)?\d{4}-\d{2}-\d{2}T.*Z ")