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
« 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"""
5from __future__ import annotations
7import hashlib
8from typing import TYPE_CHECKING
10import click
12from .config import ScopeModel
14if TYPE_CHECKING:
15 from .matches import ChangeMatches
17# Base colors for scope names (cycle through these)
18SCOPE_COLORS = ["green", "yellow", "blue", "magenta", "cyan", "red"]
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)]
28def get_scope_display(scope: ScopeModel) -> str:
29 """Get a colored display string for a scope.
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)
37 return click.style(scope.printed_name(), fg=color)
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
48class MatchesPrinter:
49 """Handles printing of file/scope matches."""
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
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())
65 if not all_paths:
66 click.echo("No files found.")
67 return
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
88 if not all_paths:
89 click.echo(f"No files found matching scopes: {', '.join(scope_filter)}")
90 return
92 # Sort paths for consistent output
93 for path in sorted(all_paths):
94 line = path
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))
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)
119 def print_by_scope(self, scope_filter: tuple[str, ...] | None = None) -> None:
120 """Print matches organized by scope."""
121 printed_any = False
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
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 )
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
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))
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))
171 line = path + " " + "".join(badges)
172 click.echo(line)
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)
181 if not printed_any:
182 click.echo("No scopes found with matching files.")
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
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)
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)
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}"
212 badges = []
213 for scope_name in code_match.scopes:
214 badges.append(print_scope_badge(scope_name, self.matches.scopes))
216 code_patterns.append(
217 (code_match.start_line, f"{location} " + " ".join(badges))
218 )
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])]
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}"
234 badges = []
235 # Always show the current scope first
236 badges.append(print_scope_badge(scope_name, self.matches.scopes))
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))
248 code_patterns.append(
249 (code_match.start_line, f"{location} " + "".join(badges))
250 )
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])]