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

1"""Parser for Black output. 

2 

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). 

8 

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""" 

14 

15from __future__ import annotations 

16 

17import re 

18from collections.abc import Iterable 

19 

20from loguru import logger 

21 

22from lintro.parsers.base_parser import strip_ansi_codes 

23from lintro.parsers.black.black_issue import BlackIssue 

24 

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) 

35 

36 

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 

43 

44 

45def parse_black_output(output: str) -> list[BlackIssue]: 

46 """Parse Black CLI output into a list of ``BlackIssue`` objects. 

47 

48 Args: 

49 output: Raw stdout+stderr from a Black invocation. 

50 

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 [] 

58 

59 # Strip ANSI codes for consistent parsing across environments 

60 output = strip_ansi_codes(output) 

61 

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 

85 

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}") 

114 

115 return issues