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

142 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-09-30 14:08 -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 OwnershipChoices, 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 color = get_color_for_name(scope.name) 

31 dim = scope.ownership == OwnershipChoices.GLOBAL 

32 

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

34 

35 

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

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

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

39 if scope_name in scopes: 

40 return arrow + get_scope_display(scopes[scope_name]) 

41 return arrow + scope_name 

42 

43 

44class MatchesPrinter: 

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

46 

47 def __init__( 

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

49 ) -> None: 

50 self.matches = matches 

51 self.all_files = all_files 

52 

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

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

55 # Use all_files if provided, otherwise just matched paths 

56 if self.all_files is not None: 

57 all_paths = self.all_files 

58 else: 

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

60 

61 if not all_paths: 

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

63 return 

64 

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

66 if scope_filter: 

67 filtered_paths = [] 

68 for path in all_paths: 

69 # Check if path matches any of the filter scopes 

70 if path in self.matches.paths: 

71 path_match = self.matches.paths[path] 

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

73 filtered_paths.append(path) 

74 # Also check code matches for this path 

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

76 if code_match.path == path and any( 

77 s in scope_filter for s in code_match.scopes 

78 ): 

79 if path not in filtered_paths: 

80 filtered_paths.append(path) 

81 break 

82 all_paths = filtered_paths 

83 

84 if not all_paths: 

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

86 return 

87 

88 # Sort paths for consistent output 

89 for path in sorted(all_paths): 

90 line = path 

91 

92 # Get scope badges if file has scopes 

93 if path in self.matches.paths: 

94 path_match = self.matches.paths[path] 

95 if path_match.scopes: 

96 badges = [] 

97 for scope_name in path_match.scopes: 

98 badges.append( 

99 print_scope_badge(scope_name, self.matches.scopes) 

100 ) 

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

102 click.echo(line) 

103 else: 

104 # Dim files without scopes 

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

106 else: 

107 # Dim files without scopes 

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

109 

110 # Print code patterns for this file if any 

111 code_patterns = self._get_file_code_patterns_simple(path) 

112 for pattern_line in code_patterns: 

113 click.echo(" " + pattern_line) 

114 

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

116 """Print matches organized by scope.""" 

117 printed_any = False 

118 

119 # Filter scopes if a filter is provided 

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

121 if scope_filter: 

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

123 if not scopes_to_show: 

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

125 return 

126 

127 for scope_name in scopes_to_show: 

128 paths_for_scope = self._get_paths_for_scope(scope_name) 

129 code_only_files = ( 

130 self._get_code_only_files_for_scope(scope_name) 

131 if not paths_for_scope 

132 else [] 

133 ) 

134 

135 if paths_for_scope or code_only_files: 

136 printed_any = True 

137 # Use the scope's color for the header 

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

139 if scope: 

140 color = get_color_for_name(scope_name) 

141 dim = scope.ownership == OwnershipChoices.GLOBAL 

142 click.secho(f"\n{scope_name}", bold=True, fg=color, dim=dim) 

143 else: 

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

145 # Combine path matches and code-only files 

146 all_files_for_scope = paths_for_scope + code_only_files 

147 

148 # Sort and print paths 

149 for path in sorted(all_files_for_scope): 

150 # Always show the current scope badge 

151 badges = [] 

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

153 

154 # Show if file belongs to OTHER scopes too 

155 if path in self.matches.paths: 

156 path_match = self.matches.paths[path] 

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

158 if other_scopes: 

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

160 for other_scope in sorted(other_scopes): 

161 badges.append( 

162 print_scope_badge(other_scope, self.matches.scopes) 

163 ) 

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

165 

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

167 click.echo(line) 

168 

169 # Print code patterns for this file if any 

170 code_patterns = self._get_file_code_patterns_simple_for_scope( 

171 path, scope_name 

172 ) 

173 for pattern_line in code_patterns: 

174 click.echo(" " + pattern_line) 

175 

176 if not printed_any: 

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

178 

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

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

181 paths = [] 

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

183 if scope_name in path_match.scopes: 

184 paths.append(path) 

185 return paths 

186 

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

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

189 code_files = set() 

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

191 if scope_name in code_match.scopes: 

192 code_files.add(code_match.path) 

193 

194 # Remove files that already match via paths 

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

196 return sorted(code_files - path_files) 

197 

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

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

200 code_patterns = [] 

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

202 if code_match.path == path: 

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

204 if code_match.start_line != code_match.end_line: 

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

206 

207 badges = [] 

208 for scope_name in code_match.scopes: 

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

210 

211 code_patterns.append( 

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

213 ) 

214 

215 # Sort by line number and return just the strings 

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

217 

218 def _get_file_code_patterns_simple_for_scope( 

219 self, path: str, scope_name: str 

220 ) -> list[str]: 

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

222 code_patterns = [] 

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

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

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

226 if code_match.start_line != code_match.end_line: 

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

228 

229 badges = [] 

230 # Always show the current scope first 

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

232 

233 # Show other scopes if any 

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

235 if other_scopes: 

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

237 for other_scope in sorted(other_scopes): 

238 badges.append( 

239 print_scope_badge(other_scope, self.matches.scopes) 

240 ) 

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

242 

243 code_patterns.append( 

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

245 ) 

246 

247 # Sort by line number and return just the strings 

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