From eb44f5920f7b5761085b0d030cb44611caa1c5f5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 4 Jul 2026 22:31:36 +0200 Subject: [PATCH 1/2] Modernize compare-html CLI (compare_output_cli) - Rename compare_output.py to compare_output_cli.py - Move shared comparison primitives (compare_files, comparable_file, ...) and logging helpers into common.py so the CLI and server share them instead of the server importing from a CLI module - Rich-based CLI: live progress bar showing in-flight files transiently, keeping only failures visible, with a failure summary at the end - Flat full-path output instead of a tree view, matching the server - Parallel comparison workers (-j/--max-workers) with one browser per worker thread, as in the server - Degrade gracefully in CI / non-TTY: no live region, periodic heartbeat, and GitHub Actions ::error:: annotations for failures - Add -v/--verbose, --log-file, --log-file-verbosity logging options - Add --driver none to compare only JSON / byte-identical files (no browser) - Add rich dependency and repoint the compare-html entry point Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SujCMUVctCunqAft7ttLFd --- pyproject.toml | 3 +- src/htmlcmp/common.py | 118 +++++++++ src/htmlcmp/compare_output.py | 277 --------------------- src/htmlcmp/compare_output_cli.py | 356 +++++++++++++++++++++++++++ src/htmlcmp/compare_output_server.py | 49 +--- 5 files changed, 481 insertions(+), 322 deletions(-) delete mode 100755 src/htmlcmp/compare_output.py create mode 100644 src/htmlcmp/compare_output_cli.py diff --git a/pyproject.toml b/pyproject.toml index 890bc17..5dc6d9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,10 +17,11 @@ dependencies = [ "selenium", "flask", "watchdog", + "rich", ] [project.scripts] -"compare-html" = "htmlcmp.compare_output:main" +"compare-html" = "htmlcmp.compare_output_cli:main" "compare-html-server" = "htmlcmp.compare_output_server:main" "html-render-diff" = "htmlcmp.html_render_diff:main" "html-tidy" = "htmlcmp.tidy_output:main" diff --git a/src/htmlcmp/common.py b/src/htmlcmp/common.py index 5973ab4..4ab4154 100644 --- a/src/htmlcmp/common.py +++ b/src/htmlcmp/common.py @@ -1,3 +1,12 @@ +import sys +import json +import logging +import filecmp +from pathlib import Path + +from htmlcmp.html_render_diff import get_browser, html_render_diff + + class bcolors: HEADER = "\033[95m" OKBLUE = "\033[94m" @@ -8,3 +17,112 @@ class bcolors: ENDC = "\033[0m" BOLD = "\033[1m" UNDERLINE = "\033[4m" + + +def parse_json(path: Path) -> dict: + if not isinstance(path, Path): + raise TypeError(f"Expected Path, got {type(path)}") + if not path.is_file(): + raise FileNotFoundError(f"File not found: {path}") + + with open(path) as f: + return json.load(f) + + +def compare_json(a: Path, b: Path) -> bool: + if not isinstance(a, Path) or not isinstance(b, Path): + raise TypeError("Both arguments must be of type Path") + if not a.is_file() or not b.is_file(): + raise FileNotFoundError("Both arguments must be files") + + json_a = json.dumps(parse_json(a), sort_keys=True) + json_b = json.dumps(parse_json(b), sort_keys=True) + return json_a == json_b + + +def compare_html(a: Path, b: Path, browser=None, diff_output: Path = None) -> bool: + if not isinstance(a, Path) or not isinstance(b, Path): + raise TypeError("Both arguments must be of type Path") + if not a.is_file() or not b.is_file(): + raise FileNotFoundError("Both arguments must be files") + + if browser is None: + browser = get_browser() + diff, (image_a, image_b) = html_render_diff(a, b, browser=browser) + result = True if diff.getbbox() is None else False + if diff_output is not None and not result: + diff_output.mkdir(parents=True, exist_ok=True) + image_a.save(diff_output / "a.png") + image_b.save(diff_output / "b.png") + diff.save(diff_output / "diff.png") + return result + + +def compare_files(a: Path, b: Path, **kwargs) -> bool: + if not isinstance(a, Path) or not isinstance(b, Path): + raise TypeError("Both arguments must be of type Path") + if not a.is_file() or not b.is_file(): + raise FileNotFoundError("Both arguments must be files") + + if filecmp.cmp(a, b): + return True + if a.suffix == ".json": + return compare_json(a, b) + if a.suffix == ".html": + return compare_html(a, b, **kwargs) + + +def comparable_file(path: Path) -> bool: + if not isinstance(path, Path): + raise TypeError(f"Expected Path, got {type(path)}") + if not path.is_file(): + raise FileNotFoundError(f"File not found: {path}") + + if path.suffix == ".json": + return True + if path.suffix == ".html": + return True + return False + + +def verbosity_to_level(verbosity: int) -> int: + if verbosity >= 3: + return logging.DEBUG + elif verbosity == 2: + return logging.INFO + elif verbosity == 1: + return logging.WARNING + else: + return logging.ERROR + + +def setup_logging( + verbosity: int, log_file: Path = None, log_file_verbosity: int = None +) -> None: + level = verbosity_to_level(verbosity) + + root_logger = logging.getLogger() + root_logger.setLevel(level) + root_logger.handlers.clear() + + formatter = logging.Formatter( + fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + if log_file is not None: + file_level = ( + verbosity_to_level(log_file_verbosity) + if log_file_verbosity is not None + else level + ) + + file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8") + file_handler.setLevel(file_level) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) diff --git a/src/htmlcmp/compare_output.py b/src/htmlcmp/compare_output.py deleted file mode 100755 index 2b9994e..0000000 --- a/src/htmlcmp/compare_output.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import sys -import argparse -import json -import threading -import filecmp -from pathlib import Path -from concurrent.futures import ThreadPoolExecutor - -from htmlcmp.html_render_diff import get_browser, html_render_diff -from htmlcmp.common import bcolors - - -class Config: - thread_local = threading.local() - - -def parse_json(path: Path) -> dict: - if not isinstance(path, Path): - raise TypeError(f"Expected Path, got {type(path)}") - if not path.is_file(): - raise FileNotFoundError(f"File not found: {path}") - - with open(path) as f: - return json.load(f) - - -def compare_json(a: Path, b: Path) -> bool: - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Both arguments must be of type Path") - if not a.is_file() or not b.is_file(): - raise FileNotFoundError("Both arguments must be files") - - json_a = json.dumps(parse_json(a), sort_keys=True) - json_b = json.dumps(parse_json(b), sort_keys=True) - return json_a == json_b - - -def compare_html(a: Path, b: Path, browser=None, diff_output: Path = None) -> bool: - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Both arguments must be of type Path") - if not a.is_file() or not b.is_file(): - raise FileNotFoundError("Both arguments must be files") - - if browser is None: - browser = get_browser() - diff, (image_a, image_b) = html_render_diff(a, b, browser=browser) - result = True if diff.getbbox() is None else False - if diff_output is not None and not result: - diff_output.mkdir(parents=True, exist_ok=True) - image_a.save(diff_output / "a.png") - image_b.save(diff_output / "b.png") - diff.save(diff_output / "diff.png") - return result - - -def compare_files(a: Path, b: Path, **kwargs) -> bool: - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Both arguments must be of type Path") - if not a.is_file() or not b.is_file(): - raise FileNotFoundError("Both arguments must be files") - - if filecmp.cmp(a, b): - return True - if a.suffix == ".json": - return compare_json(a, b) - if a.suffix == ".html": - return compare_html(a, b, **kwargs) - - -def comparable_file(path: Path) -> bool: - if not isinstance(path, Path): - raise TypeError(f"Expected Path, got {type(path)}") - if not path.is_file(): - raise FileNotFoundError(f"File not found: {path}") - - if path.suffix == ".json": - return True - if path.suffix == ".html": - return True - return False - - -def submit_compare_dirs( - a: Path, b: Path, executor, diff_output: Path = None, **kwargs -) -> dict[str, list[Path]]: - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Both arguments must be of type Path") - if not a.is_dir() or not b.is_dir(): - raise FileNotFoundError("Both arguments must be directories") - - results = { - "common_dirs": [], - "common_files": [], - "left_files_missing": [], - "right_files_missing": [], - "left_dirs_missing": [], - "right_dirs_missing": [], - } - - left = sorted(p.name for p in a.iterdir()) - right = sorted(p.name for p in b.iterdir()) - - left_files = sorted( - [name for name in left if (a / name).is_file() and comparable_file(a / name)] - ) - right_files = sorted( - [name for name in right if (b / name).is_file() and comparable_file(b / name)] - ) - common_files = [name for name in left_files if name in right_files] - - def compare(path_a, path_b, diff_output): - browser = getattr(Config.thread_local, "browser", None) - return compare_files(path_a, path_b, browser=browser, diff_output=diff_output) - - for name in common_files: - future = executor.submit( - compare, - a / name, - b / name, - diff_output=(None if diff_output is None else diff_output / name), - ) - results["common_files"].append((name, future)) - - results["left_files_missing"] = [ - name for name in right_files if name not in left_files - ] - results["right_files_missing"] = [ - name for name in left_files if name not in right_files - ] - - left_dirs = sorted([name for name in left if (a / name).is_dir()]) - right_dirs = sorted([name for name in right if (b / name).is_dir()]) - common_dirs = [path for path in left_dirs if path in right_dirs] - - for name in common_dirs: - sub_results = submit_compare_dirs( - a / name, - b / name, - executor=executor, - diff_output=(None if diff_output is None else diff_output / name), - **kwargs, - ) - results["common_dirs"].append((name, sub_results)) - - results["left_dirs_missing"] = [ - name for name in right_dirs if name not in left_dirs - ] - results["right_dirs_missing"] = [ - name for name in left_dirs if name not in right_dirs - ] - - return results - - -def print_results( - results: dict[str, list[Path]], a: Path, b: Path, level: int = 0, prefix: str = "" -) -> dict[str, list[Path]]: - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Both arguments must be of type Path") - if not a.is_dir() or not b.is_dir(): - raise FileNotFoundError("Both arguments must be directories") - if not isinstance(results, dict): - raise TypeError("Results must be a dictionary") - - prefix_file = prefix + "├── " - if level == 0: - print(f"compare dir {a} with {b}") - - result = { - "left_files_missing": [], - "right_files_missing": [], - "left_dirs_missing": [], - "right_dirs_missing": [], - "files_different": [], - } - - left_files_missing = " ".join(results["left_files_missing"]) - if left_files_missing: - print( - f"{prefix_file}{bcolors.FAIL}missing files left: {left_files_missing} ✘{bcolors.ENDC}" - ) - result["left_files_missing"].extend( - [a / name for name in results["left_files_missing"]] - ) - right_files_missing = " ".join(results["right_files_missing"]) - if right_files_missing: - print( - f"{prefix_file}{bcolors.FAIL}missing files right: {right_files_missing} ✘{bcolors.ENDC}" - ) - result["right_files_missing"].extend( - [a / name for name in results["right_files_missing"]] - ) - - for name, future in results["common_files"]: - cmp = future.result() - if cmp: - print(f"{prefix_file}{bcolors.OKGREEN}{name} ✓{bcolors.ENDC}") - else: - print(f"{prefix_file}{bcolors.FAIL}{name} ✘{bcolors.ENDC}") - result["files_different"].append(a / name) - - left_dirs_missing = " ".join(results["left_dirs_missing"]) - if left_dirs_missing: - print( - f"{prefix_file}{bcolors.FAIL}missing dirs left: {left_dirs_missing} ✘{bcolors.ENDC}" - ) - result["left_dirs_missing"].extend( - [a / name for name in results["left_dirs_missing"]] - ) - right_dirs_missing = " ".join(results["right_dirs_missing"]) - if right_dirs_missing: - print( - f"{prefix_file}{bcolors.FAIL}missing dirs right: {right_dirs_missing} ✘{bcolors.ENDC}" - ) - result["right_dirs_missing"].extend( - [a / name for name in results["right_dirs_missing"]] - ) - - for name, sub_results in results["common_dirs"]: - print(prefix + "├── " + name) - sub_result = print_results( - sub_results, - a / name, - b / name, - level=level + 1, - prefix=prefix + "│ ", - ) - result["left_files_missing"].extend(sub_result["left_files_missing"]) - result["right_files_missing"].extend(sub_result["right_files_missing"]) - result["left_dirs_missing"].extend(sub_result["left_dirs_missing"]) - result["right_dirs_missing"].extend(sub_result["right_dirs_missing"]) - result["files_different"].extend(sub_result["files_different"]) - - return result - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("a", type=Path, help="Path to the first directory") - parser.add_argument("b", type=Path, help="Path to the second directory") - parser.add_argument("--driver", choices=["chrome", "firefox"], default="firefox") - parser.add_argument( - "--diff-output", type=Path, help="Output directory for diff images" - ) - parser.add_argument("--max-workers", type=int, default=1) - args = parser.parse_args() - - def initializer(): - browser = getattr(Config.thread_local, "browser", None) - if browser is None: - browser = get_browser(driver=args.driver) - Config.thread_local.browser = browser - - executor = ThreadPoolExecutor(max_workers=args.max_workers, initializer=initializer) - - results = submit_compare_dirs( - args.a, args.b, executor=executor, diff_output=args.diff_output - ) - - result = print_results(results, args.a, args.b) - if ( - result["left_files_missing"] - or result["right_files_missing"] - or result["left_dirs_missing"] - or result["right_dirs_missing"] - or result["files_different"] - ): - return 1 - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/htmlcmp/compare_output_cli.py b/src/htmlcmp/compare_output_cli.py new file mode 100644 index 0000000..dded092 --- /dev/null +++ b/src/htmlcmp/compare_output_cli.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import logging +import argparse +import threading +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed + +from rich.console import Console +from rich.markup import escape +from rich.progress import ( + Progress, + SpinnerColumn, + BarColumn, + TextColumn, + MofNCompleteColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) + +from htmlcmp.html_render_diff import get_browser +from htmlcmp.common import comparable_file, compare_files, setup_logging + +logger = logging.getLogger(__name__) + + +class Config: + thread_local = threading.local() + + +class Task: + """A single file comparison between the reference (A) and monitored (B) tree.""" + + def __init__(self, rel: Path, a: Path, b: Path, diff_output: Path = None): + self.rel = rel + self.a = a + self.b = b + self.diff_output = diff_output + + +class Failure: + """A file that differs, is missing on one side, or errored while comparing. + + ``kind`` is one of "different", "missing", or "error". + """ + + def __init__(self, rel: Path, kind: str, reason: str): + self.rel = rel + self.kind = kind + self.reason = reason + + +def collect_tasks( + a: Path, b: Path, root: Path = None, diff_output: Path = None +) -> tuple[list[Task], list[Failure]]: + """Walk both trees once and return (comparable tasks, structural failures). + + Structural failures are files/directories present on only one side; they are + known to differ up-front and never get submitted to the executor. + """ + if not isinstance(a, Path) or not isinstance(b, Path): + raise TypeError("Both arguments must be of type Path") + if not a.is_dir() or not b.is_dir(): + raise FileNotFoundError("Both arguments must be directories") + + if root is None: + root = a + + tasks: list[Task] = [] + failures: list[Failure] = [] + + left = sorted(p.name for p in a.iterdir()) + right = sorted(p.name for p in b.iterdir()) + + left_files = { + name for name in left if (a / name).is_file() and comparable_file(a / name) + } + right_files = { + name for name in right if (b / name).is_file() and comparable_file(b / name) + } + + for name in sorted(left_files | right_files): + rel = (a / name).relative_to(root) + if name in left_files and name in right_files: + tasks.append( + Task( + rel, + a / name, + b / name, + None if diff_output is None else diff_output / name, + ) + ) + elif name in left_files: + failures.append(Failure(rel, "missing", "missing in monitored (B)")) + else: + failures.append(Failure(rel, "missing", "missing in reference (A)")) + + left_dirs = {name for name in left if (a / name).is_dir()} + right_dirs = {name for name in right if (b / name).is_dir()} + + for name in sorted(left_dirs & right_dirs): + sub_tasks, sub_failures = collect_tasks( + a / name, + b / name, + root=root, + diff_output=None if diff_output is None else diff_output / name, + ) + tasks.extend(sub_tasks) + failures.extend(sub_failures) + + for name in sorted(left_dirs - right_dirs): + failures.append( + Failure((a / name).relative_to(root), "missing", "missing in monitored (B)") + ) + for name in sorted(right_dirs - left_dirs): + failures.append( + Failure((b / name).relative_to(root), "missing", "missing in reference (A)") + ) + + return tasks, failures + + +def run_task(task: Task) -> bool: + logger.debug("Comparing %s", task.rel) + browser = getattr(Config.thread_local, "browser", None) + return compare_files(task.a, task.b, browser=browser, diff_output=task.diff_output) + + +def make_executor(max_workers: int, driver: str | None) -> ThreadPoolExecutor: + def initializer(): + # One browser per worker thread. Without a driver we only ever compare + # JSON / byte-identical files, so no browser is needed. + if driver is not None: + if getattr(Config.thread_local, "browser", None) is None: + logger.info("Starting %s browser for worker thread", driver) + Config.thread_local.browser = get_browser(driver=driver) + + logger.info("Creating executor with %d worker(s)", max_workers) + return ThreadPoolExecutor(max_workers=max_workers, initializer=initializer) + + +def github_error(rel: Path, reason: str) -> None: + """Emit a GitHub Actions error annotation so failures surface in the UI.""" + # https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions + print(f"::error file={rel}::{reason}") + + +def _resolve(task: Task, future, failures: list[Failure], github: bool): + """Resolve a finished future into an optional Failure. Returns it or None.""" + try: + same = future.result() + except Exception as exc: # noqa: BLE001 - surface any comparison error as a failure + logger.exception("Error comparing %s", task.rel) + failure = Failure(task.rel, "error", f"error: {exc}") + else: + if same: + logger.debug("Match: %s", task.rel) + return None + logger.info("Difference: %s", task.rel) + failure = Failure(task.rel, "different", "different") + + failures.append(failure) + if github: + github_error(failure.rel, failure.reason) + return failure + + +def _run_live(future_to_task, console, failures, github): + progress = Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + transient=True, + ) + with progress: + bar = progress.add_task("comparing…", total=len(future_to_task)) + for future in as_completed(future_to_task): + task = future_to_task[future] + # Transient line shows what's flowing through; only failures persist. + progress.update(bar, description=str(task.rel)) + failure = _resolve(task, future, failures, github) + if failure is not None: + progress.console.print( + f"[red]✘[/red] {escape(str(failure.rel))} " + f"[red]— {escape(failure.reason)}[/red]" + ) + progress.advance(bar) + + +def _run_plain(future_to_task, console, failures, github): + # No live region in CI / non-TTY: print failures as they happen plus a + # periodic heartbeat so long runs still show they're alive. + total = len(future_to_task) + step = max(1, total // 20) + done = 0 + for future in as_completed(future_to_task): + task = future_to_task[future] + failure = _resolve(task, future, failures, github) + if failure is not None: + console.print( + f"[red]✘[/red] {escape(str(failure.rel))} " + f"[red]— {escape(failure.reason)}[/red]" + ) + done += 1 + if done % step == 0 or done == total: + console.print(f"[dim] … {done}/{total} compared[/dim]") + + +def _print_summary(console, total: int, failures: list[Failure]) -> None: + console.rule("[bold]Summary") + + n_diff = sum(1 for f in failures if f.kind == "different") + n_error = sum(1 for f in failures if f.kind == "error") + n_missing = sum(1 for f in failures if f.kind == "missing") + matched = total - n_diff - n_error + + if not failures: + console.print(f"[green]✓ All {total} file(s) match.[/green]") + return + + parts = [f"[green]{matched} matched[/green]"] + if n_diff: + parts.append(f"[red]{n_diff} different[/red]") + if n_missing: + parts.append(f"[red]{n_missing} missing[/red]") + if n_error: + parts.append(f"[red]{n_error} error[/red]") + console.print(", ".join(parts)) + + console.print("\n[red bold]Failures:[/red bold]") + for f in sorted(failures, key=lambda f: (f.kind, str(f.rel))): + console.print( + f" [red]{escape(str(f.rel))}[/red] [dim]— {escape(f.reason)}[/dim]" + ) + + +def run( + a: Path, + b: Path, + *, + driver: str | None, + max_workers: int, + diff_output: Path | None, + console: Console, + live: bool, + github: bool, +) -> int: + console.print(f"[bold]Comparing[/bold] {escape(str(a))} [dim]→[/dim] {escape(str(b))}") + + tasks, failures = collect_tasks(a, b, diff_output=diff_output) + logger.info("Collected %d comparable file(s), %d structural difference(s)", + len(tasks), len(failures)) + + # Report structural failures (missing files/dirs) up-front. + for f in sorted(failures, key=lambda f: str(f.rel)): + console.print( + f"[red]✘[/red] {escape(str(f.rel))} [red]— {escape(f.reason)}[/red]" + ) + if github: + github_error(f.rel, f.reason) + + total = len(tasks) + if total == 0: + console.print("[dim]No comparable files to compare.[/dim]") + else: + executor = make_executor(max_workers, driver) + try: + future_to_task = {executor.submit(run_task, t): t for t in tasks} + if live: + _run_live(future_to_task, console, failures, github) + else: + _run_plain(future_to_task, console, failures, github) + finally: + executor.shutdown(wait=True) + + _print_summary(console, total, failures) + + return 1 if failures else 0 + + +def main(): + parser = argparse.ArgumentParser( + prog="compare-html", + description="Compare two directory trees of rendered HTML/JSON files.", + ) + parser.add_argument("a", type=Path, help="Reference directory (A)") + parser.add_argument("b", type=Path, help="Monitored directory (B)") + parser.add_argument( + "--driver", + choices=["chrome", "firefox", "none"], + default="firefox", + help="Browser used to render HTML diffs; 'none' compares only " + "JSON and byte-identical files (default: firefox)", + ) + parser.add_argument( + "--diff-output", type=Path, help="Directory to write diff images for mismatches" + ) + parser.add_argument( + "-j", + "--max-workers", + type=int, + default=1, + help="Number of parallel comparison workers (default: 1)", + ) + parser.add_argument( + "--no-progress", + action="store_true", + help="Disable the live progress bar (forced off when not a TTY / in CI)", + ) + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase verbosity (-v, -vv, -vvv)", + ) + parser.add_argument("--log-file", type=Path, help="Path to log file") + parser.add_argument( + "--log-file-verbosity", type=int, help="Log file verbosity level" + ) + args = parser.parse_args() + + setup_logging(args.verbose, args.log_file, args.log_file_verbosity) + + if not args.a.is_dir() or not args.b.is_dir(): + print(f"Both arguments must be directories: {args.a} {args.b}", file=sys.stderr) + return 2 + + driver = None if args.driver == "none" else args.driver + + console = Console() + github = os.environ.get("GITHUB_ACTIONS") == "true" + in_ci = bool(os.environ.get("CI")) + live = console.is_terminal and not in_ci and not args.no_progress + + return run( + args.a, + args.b, + driver=driver, + max_workers=args.max_workers, + diff_output=args.diff_output, + console=console, + live=live, + github=github, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py index d5579b1..8d10966 100755 --- a/src/htmlcmp/compare_output_server.py +++ b/src/htmlcmp/compare_output_server.py @@ -15,7 +15,11 @@ import watchdog.observers import watchdog.events -from htmlcmp.compare_output import comparable_file, compare_files +from htmlcmp.common import ( + comparable_file, + compare_files, + setup_logging, +) from htmlcmp.html_render_diff import get_browser, html_render_diff logger = logging.getLogger(__name__) @@ -775,49 +779,6 @@ def update_ref(path: str): return "Reference updated", 200 -def verbosity_to_level(verbosity: int) -> int: - if verbosity >= 3: - return logging.DEBUG - elif verbosity == 2: - return logging.INFO - elif verbosity == 1: - return logging.WARNING - else: - return logging.ERROR - - -def setup_logging( - verbosity: int, log_file: Path = None, log_file_verbosity: int = None -) -> None: - level = verbosity_to_level(verbosity) - - root_logger = logging.getLogger() - root_logger.setLevel(level) - root_logger.handlers.clear() - - formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - console_handler = logging.StreamHandler(sys.stderr) - console_handler.setLevel(level) - console_handler.setFormatter(formatter) - root_logger.addHandler(console_handler) - - if log_file is not None: - file_level = ( - verbosity_to_level(log_file_verbosity) - if log_file_verbosity is not None - else level - ) - - file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8") - file_handler.setLevel(file_level) - file_handler.setFormatter(formatter) - root_logger.addHandler(file_handler) - - def main(): parser = argparse.ArgumentParser() parser.add_argument("ref", type=Path, help="Path to the reference directory") From bf2d5c83ff621cad80d3834583a848db2aad1f54 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 4 Jul 2026 22:32:56 +0200 Subject: [PATCH 2/2] format --- src/htmlcmp/compare_output_cli.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/htmlcmp/compare_output_cli.py b/src/htmlcmp/compare_output_cli.py index dded092..5b20d27 100644 --- a/src/htmlcmp/compare_output_cli.py +++ b/src/htmlcmp/compare_output_cli.py @@ -252,11 +252,16 @@ def run( live: bool, github: bool, ) -> int: - console.print(f"[bold]Comparing[/bold] {escape(str(a))} [dim]→[/dim] {escape(str(b))}") + console.print( + f"[bold]Comparing[/bold] {escape(str(a))} [dim]→[/dim] {escape(str(b))}" + ) tasks, failures = collect_tasks(a, b, diff_output=diff_output) - logger.info("Collected %d comparable file(s), %d structural difference(s)", - len(tasks), len(failures)) + logger.info( + "Collected %d comparable file(s), %d structural difference(s)", + len(tasks), + len(failures), + ) # Report structural failures (missing files/dirs) up-front. for f in sorted(failures, key=lambda f: str(f.rel)):