|
2 | 2 |
|
3 | 3 | import hashlib |
4 | 4 | import json |
| 5 | +import sys |
5 | 6 | from dataclasses import dataclass, field |
6 | 7 | from enum import IntEnum, auto |
7 | 8 | from pathlib import Path |
8 | | -from typing import Dict, List, Optional |
| 9 | +from typing import Any, Callable, Dict, List, Optional, TypeVar |
9 | 10 |
|
10 | 11 | import click |
| 12 | +from rich.console import Console |
| 13 | +from rich.markup import escape as rich_escape |
11 | 14 |
|
12 | 15 |
|
13 | 16 | class HashableItemType(IntEnum): |
@@ -42,26 +45,43 @@ def hash(self) -> bytes: |
42 | 45 | all_hash_bytes += item_hash_bytes |
43 | 46 | return hashlib.sha256(all_hash_bytes).digest() |
44 | 47 |
|
45 | | - def print( |
| 48 | + def format_lines( |
46 | 49 | self, |
47 | 50 | *, |
48 | 51 | name: str, |
49 | 52 | level: int = 0, |
50 | 53 | print_type: Optional[HashableItemType] = None, |
51 | | - ) -> None: |
52 | | - """Print the hash of the item and sub-items.""" |
| 54 | + max_depth: Optional[int] = None, |
| 55 | + ) -> List[str]: |
| 56 | + """Return the hash lines for the item and sub-items.""" |
| 57 | + lines: List[str] = [] |
53 | 58 | next_level = level |
54 | 59 | print_name = name |
| 60 | + |
55 | 61 | if level == 0 and self.parents: |
56 | 62 | separator = "::" if self.type == HashableItemType.TEST else "/" |
57 | 63 | print_name = f"{'/'.join(self.parents)}{separator}{name}" |
| 64 | + |
58 | 65 | if print_type is None or self.type >= print_type: |
59 | 66 | next_level += 1 |
60 | | - print(f"{' ' * level}{print_name}: 0x{self.hash().hex()}") |
| 67 | + lines.append(f"{' ' * level}{print_name}: 0x{self.hash().hex()}") |
| 68 | + |
| 69 | + # Stop recursion if we've reached max_depth |
| 70 | + if max_depth is not None and next_level > max_depth: |
| 71 | + return lines |
61 | 72 |
|
62 | 73 | if self.items is not None: |
63 | 74 | for key, item in sorted(self.items.items()): |
64 | | - item.print(name=key, level=next_level, print_type=print_type) |
| 75 | + lines.extend( |
| 76 | + item.format_lines( |
| 77 | + name=key, |
| 78 | + level=next_level, |
| 79 | + print_type=print_type, |
| 80 | + max_depth=max_depth, |
| 81 | + ) |
| 82 | + ) |
| 83 | + |
| 84 | + return lines |
65 | 85 |
|
66 | 86 | @classmethod |
67 | 87 | def from_json_file( |
@@ -126,34 +146,247 @@ def from_folder( |
126 | 146 | return cls(type=HashableItemType.FOLDER, items=items, parents=parents) |
127 | 147 |
|
128 | 148 |
|
129 | | -@click.command() |
| 149 | +def render_hash_report( |
| 150 | + folder: Path, |
| 151 | + *, |
| 152 | + files: bool, |
| 153 | + tests: bool, |
| 154 | + root: bool, |
| 155 | + name_override: Optional[str] = None, |
| 156 | + max_depth: Optional[int] = None, |
| 157 | +) -> List[str]: |
| 158 | + """Return canonical output lines for a folder.""" |
| 159 | + item = HashableItem.from_folder(folder_path=folder) |
| 160 | + if root: |
| 161 | + return [f"0x{item.hash().hex()}"] |
| 162 | + print_type: Optional[HashableItemType] = None |
| 163 | + if files: |
| 164 | + print_type = HashableItemType.FILE |
| 165 | + elif tests: |
| 166 | + print_type = HashableItemType.TEST |
| 167 | + name = name_override if name_override is not None else folder.name |
| 168 | + return item.format_lines( |
| 169 | + name=name, print_type=print_type, max_depth=max_depth |
| 170 | + ) |
| 171 | + |
| 172 | + |
| 173 | +def collect_hashes( |
| 174 | + item: HashableItem, |
| 175 | + *, |
| 176 | + path: str = "", |
| 177 | + print_type: Optional[HashableItemType] = None, |
| 178 | + max_depth: Optional[int] = None, |
| 179 | + depth: int = 0, |
| 180 | +) -> Dict[str, str]: |
| 181 | + """Collect hashes from item tree as {path: hash_hex}.""" |
| 182 | + result: Dict[str, str] = {} |
| 183 | + |
| 184 | + if print_type is None or item.type >= print_type: |
| 185 | + if path: |
| 186 | + result[path] = f"0x{item.hash().hex()}" |
| 187 | + depth += 1 |
| 188 | + if max_depth is not None and depth > max_depth: |
| 189 | + return result |
| 190 | + |
| 191 | + if item.items: |
| 192 | + for name, child in sorted(item.items.items()): |
| 193 | + child_path = f"{path}/{name}" if path else name |
| 194 | + result.update( |
| 195 | + collect_hashes( |
| 196 | + child, |
| 197 | + path=child_path, |
| 198 | + print_type=print_type, |
| 199 | + max_depth=max_depth, |
| 200 | + depth=depth, |
| 201 | + ) |
| 202 | + ) |
| 203 | + |
| 204 | + return result |
| 205 | + |
| 206 | + |
| 207 | +def display_diff( |
| 208 | + left: Dict[str, str], |
| 209 | + right: Dict[str, str], |
| 210 | + *, |
| 211 | + left_label: str, |
| 212 | + right_label: str, |
| 213 | +) -> None: |
| 214 | + """Render diff showing only changed hashes.""" |
| 215 | + differences: List[tuple[str, str, str]] = [] |
| 216 | + |
| 217 | + for path in left: |
| 218 | + right_hash = right.get(path, "<missing>") |
| 219 | + if left[path] != right_hash: |
| 220 | + differences.append((path, left[path], right_hash)) |
| 221 | + |
| 222 | + for path in right: |
| 223 | + if path not in left: |
| 224 | + differences.append((path, "<missing>", right[path])) |
| 225 | + |
| 226 | + if not differences: |
| 227 | + return |
| 228 | + |
| 229 | + console = Console() |
| 230 | + console.print("── Fixture Hash Differences ──", style="bold") |
| 231 | + console.print(f"[dim]--- {left_label}[/dim]") |
| 232 | + console.print(f"[dim]+++ {right_label}[/dim]") |
| 233 | + console.print() |
| 234 | + |
| 235 | + for path, left_hash, right_hash in differences: |
| 236 | + depth = path.count("/") |
| 237 | + indent = " " * (depth + 1) |
| 238 | + console.print(f"{indent}[bold]{rich_escape(path)}[/bold]") |
| 239 | + console.print(f"{indent} [red]- {left_hash}[/red]") |
| 240 | + console.print(f"{indent} [green]+ {right_hash}[/green]") |
| 241 | + console.print() |
| 242 | + |
| 243 | + |
| 244 | +class DefaultGroup(click.Group): |
| 245 | + """Click group with a default command fallback.""" |
| 246 | + |
| 247 | + def __init__( |
| 248 | + self, *args: Any, default_cmd_name: str = "hash", **kwargs: Any |
| 249 | + ): |
| 250 | + super().__init__(*args, **kwargs) |
| 251 | + self.default_cmd_name = default_cmd_name |
| 252 | + |
| 253 | + def resolve_command( |
| 254 | + self, ctx: click.Context, args: List[str] |
| 255 | + ) -> tuple[Optional[str], Optional[click.Command], List[str]]: |
| 256 | + """Resolve command, inserting default if no subcommand given.""" |
| 257 | + first_arg_idx = next( |
| 258 | + (i for i, a in enumerate(args) if not a.startswith("-")), None |
| 259 | + ) |
| 260 | + if ( |
| 261 | + first_arg_idx is not None |
| 262 | + and args[first_arg_idx] not in self.commands |
| 263 | + ): |
| 264 | + args = list(args) |
| 265 | + args.insert(first_arg_idx, self.default_cmd_name) |
| 266 | + return super().resolve_command(ctx, args) |
| 267 | + |
| 268 | + |
| 269 | +F = TypeVar("F", bound=Callable[..., None]) |
| 270 | + |
| 271 | + |
| 272 | +def hash_options(func: F) -> F: |
| 273 | + """Decorator for common hash options.""" |
| 274 | + func = click.option( |
| 275 | + "--root", "-r", is_flag=True, help="Only print hash of root folder" |
| 276 | + )(func) |
| 277 | + func = click.option( |
| 278 | + "--tests", "-t", is_flag=True, help="Print hash of tests" |
| 279 | + )(func) |
| 280 | + func = click.option( |
| 281 | + "--files", "-f", is_flag=True, help="Print hash of files" |
| 282 | + )(func) |
| 283 | + return func |
| 284 | + |
| 285 | + |
| 286 | +@click.group( |
| 287 | + cls=DefaultGroup, |
| 288 | + default_cmd_name="hash", |
| 289 | + context_settings={"help_option_names": ["-h", "--help"]}, |
| 290 | +) |
| 291 | +def hasher() -> None: |
| 292 | + """Hash folders of JSON fixtures and compare them.""" |
| 293 | + pass |
| 294 | + |
| 295 | + |
| 296 | +@hasher.command(name="hash") |
130 | 297 | @click.argument( |
131 | 298 | "folder_path_str", |
132 | 299 | type=click.Path( |
133 | 300 | exists=True, file_okay=False, dir_okay=True, readable=True |
134 | 301 | ), |
135 | 302 | ) |
136 | | -@click.option("--files", "-f", is_flag=True, help="Print hash of files") |
137 | | -@click.option("--tests", "-t", is_flag=True, help="Print hash of tests") |
| 303 | +@hash_options |
| 304 | +def hash_cmd( |
| 305 | + folder_path_str: str, files: bool, tests: bool, root: bool |
| 306 | +) -> None: |
| 307 | + """Hash folders of JSON fixtures and print their hashes.""" |
| 308 | + lines = render_hash_report( |
| 309 | + Path(folder_path_str), files=files, tests=tests, root=root |
| 310 | + ) |
| 311 | + for line in lines: |
| 312 | + print(line) |
| 313 | + |
| 314 | + |
| 315 | +@hasher.command(name="compare") |
| 316 | +@click.argument( |
| 317 | + "left_folder", |
| 318 | + type=click.Path( |
| 319 | + exists=True, file_okay=False, dir_okay=True, readable=True |
| 320 | + ), |
| 321 | +) |
| 322 | +@click.argument( |
| 323 | + "right_folder", |
| 324 | + type=click.Path( |
| 325 | + exists=True, file_okay=False, dir_okay=True, readable=True |
| 326 | + ), |
| 327 | +) |
138 | 328 | @click.option( |
139 | | - "--root", "-r", is_flag=True, help="Only print hash of root folder" |
| 329 | + "--depth", |
| 330 | + "-d", |
| 331 | + type=int, |
| 332 | + default=None, |
| 333 | + help="Limit to N levels (0=root, 1=folders, 2=files, 3=tests).", |
140 | 334 | ) |
141 | | -def main(folder_path_str: str, files: bool, tests: bool, root: bool) -> None: |
142 | | - """Hash folders of JSON fixtures and print their hashes.""" |
143 | | - folder_path: Path = Path(folder_path_str) |
144 | | - item = HashableItem.from_folder(folder_path=folder_path) |
| 335 | +@hash_options |
| 336 | +def compare_cmd( |
| 337 | + left_folder: str, |
| 338 | + right_folder: str, |
| 339 | + files: bool, |
| 340 | + tests: bool, |
| 341 | + root: bool, |
| 342 | + depth: Optional[int], |
| 343 | +) -> None: |
| 344 | + """Compare two fixture directories and show differences.""" |
| 345 | + try: |
| 346 | + left_item = HashableItem.from_folder(folder_path=Path(left_folder)) |
| 347 | + right_item = HashableItem.from_folder(folder_path=Path(right_folder)) |
145 | 348 |
|
146 | | - if root: |
147 | | - print(f"0x{item.hash().hex()}") |
148 | | - return |
| 349 | + if root: |
| 350 | + if left_item.hash() == right_item.hash(): |
| 351 | + sys.exit(0) |
| 352 | + left_hashes = {"root": f"0x{left_item.hash().hex()}"} |
| 353 | + right_hashes = {"root": f"0x{right_item.hash().hex()}"} |
| 354 | + else: |
| 355 | + print_type: Optional[HashableItemType] = None |
| 356 | + if files: |
| 357 | + print_type = HashableItemType.FILE |
| 358 | + elif tests: |
| 359 | + print_type = HashableItemType.TEST |
| 360 | + |
| 361 | + left_hashes = collect_hashes( |
| 362 | + left_item, print_type=print_type, max_depth=depth |
| 363 | + ) |
| 364 | + right_hashes = collect_hashes( |
| 365 | + right_item, print_type=print_type, max_depth=depth |
| 366 | + ) |
| 367 | + |
| 368 | + if left_hashes == right_hashes: |
| 369 | + sys.exit(0) |
| 370 | + |
| 371 | + display_diff( |
| 372 | + left_hashes, |
| 373 | + right_hashes, |
| 374 | + left_label=left_folder, |
| 375 | + right_label=right_folder, |
| 376 | + ) |
| 377 | + sys.exit(1) |
| 378 | + except PermissionError as e: |
| 379 | + click.echo(f"Error: Permission denied - {e}", err=True) |
| 380 | + sys.exit(2) |
| 381 | + except (json.JSONDecodeError, KeyError, TypeError) as e: |
| 382 | + click.echo(f"Error: Invalid fixture format - {e}", err=True) |
| 383 | + sys.exit(2) |
| 384 | + except Exception as e: |
| 385 | + click.echo(f"Error: {e}", err=True) |
| 386 | + sys.exit(2) |
149 | 387 |
|
150 | | - print_type: Optional[HashableItemType] = None |
151 | | - if files: |
152 | | - print_type = HashableItemType.FILE |
153 | | - elif tests: |
154 | | - print_type = HashableItemType.TEST |
155 | 388 |
|
156 | | - item.print(name=folder_path.name, print_type=print_type) |
| 389 | +main = hasher # Entry point alias |
157 | 390 |
|
158 | 391 |
|
159 | 392 | if __name__ == "__main__": |
|
0 commit comments