Coverage for lintro / parsers / black / black_parser.py: 73%
56 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Parser for Black output.
3Black commonly emits terse messages like:
4- "would reformat foo.py" (check mode with --check)
5- "reformatted foo.py" (fix mode)
6- a summary line like "1 file would be reformatted" or
7 "2 files reformatted" (with no per-file lines in some environments).
9We normalize items into ``BlackIssue`` objects so the table formatter can
10render consistent rows. When only a summary is present, we synthesize one
11``BlackIssue`` per counted file with ``file`` set to "<unknown>" so totals
12remain accurate across environments.
13"""
15from __future__ import annotations
17import re
18from collections.abc import Iterable
20from loguru import logger
22from lintro.parsers.base_parser import strip_ansi_codes
23from lintro.parsers.black.black_issue import BlackIssue
25_WOULD_REFORMAT = re.compile(r"^would reformat\s+(?P<file>.+)$", re.IGNORECASE)
26_REFORMATTED = re.compile(r"^reformatted\s+(?P<file>.+)$", re.IGNORECASE)
27_SUMMARY_WOULD = re.compile(
28 r"(?P<count>\d+)\s+file(?:s)?\s+would\s+be\s+reformatted\.?",
29 re.IGNORECASE,
30)
31_SUMMARY_REFORMATTED = re.compile(
32 r"(?P<count>\d+)\s+file(?:s)?\s+reformatted\.?",
33 re.IGNORECASE,
34)
37def _iter_issue_lines(lines: Iterable[str]) -> Iterable[str]:
38 for line in lines:
39 s = line.strip()
40 if not s:
41 continue
42 yield s
45def parse_black_output(output: str) -> list[BlackIssue]:
46 """Parse Black CLI output into a list of ``BlackIssue`` objects.
48 Args:
49 output: Raw stdout+stderr from a Black invocation.
51 Returns:
52 list[BlackIssue]: Per-file issues indicating formatting diffs. If only
53 a summary is present (no per-file lines), returns a synthesized list
54 sized to the summary count with ``file`` set to "<unknown>".
55 """
56 if not output:
57 return []
59 # Strip ANSI codes for consistent parsing across environments
60 output = strip_ansi_codes(output)
62 issues: list[BlackIssue] = []
63 try:
64 for line in _iter_issue_lines(output.splitlines()):
65 try:
66 m = _WOULD_REFORMAT.match(line)
67 if m:
68 file_match = m.group("file")
69 if file_match:
70 issues.append(
71 BlackIssue(file=file_match, message="Would reformat file"),
72 )
73 continue
74 m = _REFORMATTED.match(line)
75 if m:
76 file_match = m.group("file")
77 if file_match:
78 issues.append(
79 BlackIssue(file=file_match, message="Reformatted file"),
80 )
81 continue
82 except (AttributeError, IndexError) as e:
83 logger.debug(f"Failed to parse black line '{line}': {e}")
84 continue
86 # Some environments (e.g., CI) may emit only a summary line without listing
87 # per-file entries. In that case, synthesize issues so counts remain
88 # consistent across environments.
89 if not issues:
90 m_sum = _SUMMARY_WOULD.search(output)
91 if not m_sum:
92 m_sum = _SUMMARY_REFORMATTED.search(output)
93 if m_sum:
94 try:
95 count = int(m_sum.group("count"))
96 except (ValueError, TypeError, AttributeError) as e:
97 logger.debug(f"Failed to parse black summary count: {e}")
98 count = 0
99 if count > 0:
100 logger.info(
101 f"Black reported {count} file(s) need formatting but "
102 "didn't list them. Run 'black --check .' directly to "
103 "see affected files.",
104 )
105 for _ in range(count):
106 issues.append(
107 BlackIssue(
108 file="<unknown>",
109 message="Formatting change detected",
110 ),
111 )
112 except (ValueError, KeyError, TypeError, IndexError) as e:
113 logger.debug(f"Error parsing black output: {e}")
115 return issues