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
« 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"""
5from __future__ import annotations
7import hashlib
8from typing import TYPE_CHECKING
10import click
12from .config import OwnershipChoices, 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."""
30 color = get_color_for_name(scope.name)
31 dim = scope.ownership == OwnershipChoices.GLOBAL
33 return click.style(scope.printed_name(), fg=color, dim=dim)
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
44class MatchesPrinter:
45 """Handles printing of file/scope matches."""
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
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())
61 if not all_paths:
62 click.echo("No files found.")
63 return
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
84 if not all_paths:
85 click.echo(f"No files found matching scopes: {', '.join(scope_filter)}")
86 return
88 # Sort paths for consistent output
89 for path in sorted(all_paths):
90 line = path
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))
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)
115 def print_by_scope(self, scope_filter: tuple[str, ...] | None = None) -> None:
116 """Print matches organized by scope."""
117 printed_any = False
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
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 )
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
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))
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))
166 line = path + " " + "".join(badges)
167 click.echo(line)
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)
176 if not printed_any:
177 click.echo("No scopes found with matching files.")
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
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)
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)
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}"
207 badges = []
208 for scope_name in code_match.scopes:
209 badges.append(print_scope_badge(scope_name, self.matches.scopes))
211 code_patterns.append(
212 (code_match.start_line, f"{location} " + " ".join(badges))
213 )
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])]
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}"
229 badges = []
230 # Always show the current scope first
231 badges.append(print_scope_badge(scope_name, self.matches.scopes))
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))
243 code_patterns.append(
244 (code_match.start_line, f"{location} " + "".join(badges))
245 )
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])]