Coverage for src/pullapprove/printer.py: 33%

138 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-28 20:48 -0500

1""" 

2Printer classes for formatting and displaying PullApprove output. 

3""" 

4 

5from __future__ import annotations 

6 

7import hashlib 

8from typing import TYPE_CHECKING 

9 

10import click 

11 

12from .config import ScopeModel 

13 

14if TYPE_CHECKING: 

15 from .matches import ChangeMatches 

16 

17# Base colors for scope names (cycle through these) 

18SCOPE_COLORS = ["green", "yellow", "blue", "magenta", "cyan", "red"] 

19 

20 

21def get_color_for_name(name: str) -> str: 

22 """Get a consistent color for a given name using deterministic hash.""" 

23 # Use MD5 for a deterministic hash across Python invocations 

24 name_hash = int(hashlib.md5(name.encode()).hexdigest()[:8], 16) 

25 return SCOPE_COLORS[name_hash % len(SCOPE_COLORS)] 

26 

27 

28def get_scope_display(scope: ScopeModel) -> str: 

29 """Get a colored display string for a scope. 

30 

31 Ownership is carried by the printed marker alone, never by brightness -- 

32 dimming `global` made the one scope that can pass a file by itself the 

33 faintest thing on screen. Same rule as the web UI (see ownership.ts). 

34 """ 

35 color = get_color_for_name(scope.name) 

36 

37 return click.style(scope.printed_name(), fg=color) 

38 

39 

40def print_scope_badge(scope_name: str, scopes: dict[str, ScopeModel]) -> str: 

41 """Print a scope badge with a dimmed arrow prefix.""" 

42 arrow = click.style("→ ", dim=True) 

43 if scope_name in scopes: 

44 return arrow + get_scope_display(scopes[scope_name]) 

45 return arrow + scope_name 

46 

47 

48class MatchesPrinter: 

49 """Handles printing of file/scope matches.""" 

50 

51 def __init__( 

52 self, matches: ChangeMatches, all_files: list[str] | None = None 

53 ) -> None: 

54 self.matches = matches 

55 self.all_files = all_files 

56 

57 def print_by_path(self, scope_filter: tuple[str, ...] | None = None) -> None: 

58 """Print matches organized by file path.""" 

59 # Use all_files if provided, otherwise just matched paths 

60 if self.all_files is not None: 

61 all_paths = self.all_files 

62 else: 

63 all_paths = list(self.matches.paths.keys()) 

64 

65 if not all_paths: 

66 click.echo("No files found.") 

67 return 

68 

69 # If scope filter is provided, filter paths to only those matching the scopes 

70 if scope_filter: 

71 filtered_paths = [] 

72 for path in all_paths: 

73 # Check if path matches any of the filter scopes 

74 if path in self.matches.paths: 

75 path_match = self.matches.paths[path] 

76 if any(s in scope_filter for s in path_match.scopes): 

77 filtered_paths.append(path) 

78 # Also check code matches for this path 

79 for code_match in self.matches.code.values(): 

80 if code_match.path == path and any( 

81 s in scope_filter for s in code_match.scopes 

82 ): 

83 if path not in filtered_paths: 

84 filtered_paths.append(path) 

85 break 

86 all_paths = filtered_paths 

87 

88 if not all_paths: 

89 click.echo(f"No files found matching scopes: {', '.join(scope_filter)}") 

90 return 

91 

92 # Sort paths for consistent output 

93 for path in sorted(all_paths): 

94 line = path 

95 

96 # Get scope badges if file has scopes 

97 if path in self.matches.paths: 

98 path_match = self.matches.paths[path] 

99 if path_match.scopes: 

100 badges = [] 

101 for scope_name in path_match.scopes: 

102 badges.append( 

103 print_scope_badge(scope_name, self.matches.scopes) 

104 ) 

105 line += " " + " ".join(badges) 

106 click.echo(line) 

107 else: 

108 # Dim files without scopes 

109 click.echo(click.style(line, dim=True)) 

110 else: 

111 # Dim files without scopes 

112 click.echo(click.style(line, dim=True)) 

113 

114 # Print code patterns for this file if any 

115 code_patterns = self._get_file_code_patterns_simple(path) 

116 for pattern_line in code_patterns: 

117 click.echo(" " + pattern_line) 

118 

119 def print_by_scope(self, scope_filter: tuple[str, ...] | None = None) -> None: 

120 """Print matches organized by scope.""" 

121 printed_any = False 

122 

123 # Filter scopes if a filter is provided 

124 scopes_to_show = sorted(self.matches.scopes.keys()) 

125 if scope_filter: 

126 scopes_to_show = [s for s in scopes_to_show if s in scope_filter] 

127 if not scopes_to_show: 

128 click.echo(f"No scopes found matching: {', '.join(scope_filter)}") 

129 return 

130 

131 for scope_name in scopes_to_show: 

132 paths_for_scope = self._get_paths_for_scope(scope_name) 

133 code_only_files = ( 

134 self._get_code_only_files_for_scope(scope_name) 

135 if not paths_for_scope 

136 else [] 

137 ) 

138 

139 if paths_for_scope or code_only_files: 

140 printed_any = True 

141 # Use the scope's color for the header, and its printed name -- 

142 # the badges below spell it with the ownership marker, so the 

143 # header has to as well. 

144 scope = self.matches.scopes.get(scope_name) 

145 if scope: 

146 color = get_color_for_name(scope_name) 

147 click.secho(f"\n{scope.printed_name()}", bold=True, fg=color) 

148 else: 

149 click.secho(f"\n{scope_name}", bold=True, fg="cyan") 

150 # Combine path matches and code-only files 

151 all_files_for_scope = paths_for_scope + code_only_files 

152 

153 # Sort and print paths 

154 for path in sorted(all_files_for_scope): 

155 # Always show the current scope badge 

156 badges = [] 

157 badges.append(print_scope_badge(scope_name, self.matches.scopes)) 

158 

159 # Show if file belongs to OTHER scopes too 

160 if path in self.matches.paths: 

161 path_match = self.matches.paths[path] 

162 other_scopes = [s for s in path_match.scopes if s != scope_name] 

163 if other_scopes: 

164 badges.append(click.style("(also: ", dim=True)) 

165 for other_scope in sorted(other_scopes): 

166 badges.append( 

167 print_scope_badge(other_scope, self.matches.scopes) 

168 ) 

169 badges.append(click.style(")", dim=True)) 

170 

171 line = path + " " + "".join(badges) 

172 click.echo(line) 

173 

174 # Print code patterns for this file if any 

175 code_patterns = self._get_file_code_patterns_simple_for_scope( 

176 path, scope_name 

177 ) 

178 for pattern_line in code_patterns: 

179 click.echo(" " + pattern_line) 

180 

181 if not printed_any: 

182 click.echo("No scopes found with matching files.") 

183 

184 def _get_paths_for_scope(self, scope_name: str) -> list[str]: 

185 """Get all paths that match a specific scope.""" 

186 paths = [] 

187 for path, path_match in self.matches.paths.items(): 

188 if scope_name in path_match.scopes: 

189 paths.append(path) 

190 return paths 

191 

192 def _get_code_only_files_for_scope(self, scope_name: str) -> list[str]: 

193 """Get files that only match this scope via code patterns, not paths.""" 

194 code_files = set() 

195 for code_match in self.matches.code.values(): 

196 if scope_name in code_match.scopes: 

197 code_files.add(code_match.path) 

198 

199 # Remove files that already match via paths 

200 path_files = set(self._get_paths_for_scope(scope_name)) 

201 return sorted(code_files - path_files) 

202 

203 def _get_file_code_patterns_simple(self, path: str) -> list[str]: 

204 """Get code pattern lines for a file by its full path.""" 

205 code_patterns = [] 

206 for code_match in self.matches.code.values(): 

207 if code_match.path == path: 

208 location = f"line {code_match.start_line}" 

209 if code_match.start_line != code_match.end_line: 

210 location += f"-{code_match.end_line}" 

211 

212 badges = [] 

213 for scope_name in code_match.scopes: 

214 badges.append(print_scope_badge(scope_name, self.matches.scopes)) 

215 

216 code_patterns.append( 

217 (code_match.start_line, f"{location} " + " ".join(badges)) 

218 ) 

219 

220 # Sort by line number and return just the strings 

221 return [pattern[1] for pattern in sorted(code_patterns, key=lambda x: x[0])] 

222 

223 def _get_file_code_patterns_simple_for_scope( 

224 self, path: str, scope_name: str 

225 ) -> list[str]: 

226 """Get code pattern lines for a file filtered by a specific scope.""" 

227 code_patterns = [] 

228 for code_match in self.matches.code.values(): 

229 if code_match.path == path and scope_name in code_match.scopes: 

230 location = f"line {code_match.start_line}" 

231 if code_match.start_line != code_match.end_line: 

232 location += f"-{code_match.end_line}" 

233 

234 badges = [] 

235 # Always show the current scope first 

236 badges.append(print_scope_badge(scope_name, self.matches.scopes)) 

237 

238 # Show other scopes if any 

239 other_scopes = [s for s in code_match.scopes if s != scope_name] 

240 if other_scopes: 

241 badges.append(click.style("(also: ", dim=True)) 

242 for other_scope in sorted(other_scopes): 

243 badges.append( 

244 print_scope_badge(other_scope, self.matches.scopes) 

245 ) 

246 badges.append(click.style(")", dim=True)) 

247 

248 code_patterns.append( 

249 (code_match.start_line, f"{location} " + "".join(badges)) 

250 ) 

251 

252 # Sort by line number and return just the strings 

253 return [pattern[1] for pattern in sorted(code_patterns, key=lambda x: x[0])]