Skip to content

Commit 7c8ec4f

Browse files
authored
feat(test-cli): add hasher compare subcommand (#2080)
* feat(test-cli): add hasher compare subcommand * test(test-cli): add unit tests for hasher * refactor(test-cli): remove dead code and redundant comments - Remove unused HashableItem.print() method. - Remove obvious inline comments that duplicate code intent. - Simplify multi-line type annotation for differences list. * refactor(test-cli): simplify compare by diffing trees directly - Replace parse_hash_lines() with collect_hashes() that walks item tree. - Simplify display_diff() to work with dicts instead of parsing strings. - Eliminate round-trip: format → parse → diff is now just collect → diff.
1 parent 9325a81 commit 7c8ec4f

2 files changed

Lines changed: 598 additions & 23 deletions

File tree

packages/testing/src/execution_testing/cli/hasher.py

Lines changed: 256 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import hashlib
44
import json
5+
import sys
56
from dataclasses import dataclass, field
67
from enum import IntEnum, auto
78
from pathlib import Path
8-
from typing import Dict, List, Optional
9+
from typing import Any, Callable, Dict, List, Optional, TypeVar
910

1011
import click
12+
from rich.console import Console
13+
from rich.markup import escape as rich_escape
1114

1215

1316
class HashableItemType(IntEnum):
@@ -42,26 +45,43 @@ def hash(self) -> bytes:
4245
all_hash_bytes += item_hash_bytes
4346
return hashlib.sha256(all_hash_bytes).digest()
4447

45-
def print(
48+
def format_lines(
4649
self,
4750
*,
4851
name: str,
4952
level: int = 0,
5053
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] = []
5358
next_level = level
5459
print_name = name
60+
5561
if level == 0 and self.parents:
5662
separator = "::" if self.type == HashableItemType.TEST else "/"
5763
print_name = f"{'/'.join(self.parents)}{separator}{name}"
64+
5865
if print_type is None or self.type >= print_type:
5966
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
6172

6273
if self.items is not None:
6374
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
6585

6686
@classmethod
6787
def from_json_file(
@@ -126,34 +146,247 @@ def from_folder(
126146
return cls(type=HashableItemType.FOLDER, items=items, parents=parents)
127147

128148

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")
130297
@click.argument(
131298
"folder_path_str",
132299
type=click.Path(
133300
exists=True, file_okay=False, dir_okay=True, readable=True
134301
),
135302
)
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+
)
138328
@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).",
140334
)
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))
145348

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)
149387

150-
print_type: Optional[HashableItemType] = None
151-
if files:
152-
print_type = HashableItemType.FILE
153-
elif tests:
154-
print_type = HashableItemType.TEST
155388

156-
item.print(name=folder_path.name, print_type=print_type)
389+
main = hasher # Entry point alias
157390

158391

159392
if __name__ == "__main__":

0 commit comments

Comments
 (0)