htmlcmp 1.3.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.3.0
3
+ Version: 2.1.0
4
4
  Summary: Compare HTML files by rendered output
5
5
  Author: Andreas Stefl
6
6
  Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
@@ -9,12 +9,13 @@ Project-URL: source, https://github.com/opendocument-app/compare-html
9
9
  Project-URL: download, https://pypi.org/project/pyodr/#files
10
10
  Project-URL: tracker, https://github.com/opendocument-app/compare-html/issues
11
11
  Project-URL: release notes, https://github.com/opendocument-app/compare-html/releases
12
- Requires-Python: >=3.7
12
+ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  Requires-Dist: pillow
15
15
  Requires-Dist: selenium
16
16
  Requires-Dist: flask
17
17
  Requires-Dist: watchdog
18
+ Requires-Dist: rich
18
19
 
19
20
  # htmlcmp
20
21
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlcmp"
3
- version = "1.3.0"
3
+ version = "2.1.0"
4
4
  description = "Compare HTML files by rendered output"
5
5
  classifiers = []
6
6
  authors = [
@@ -9,7 +9,7 @@ authors = [
9
9
  maintainers = [
10
10
  {name = "Andreas Stefl", email="stefl.andreas@gmail.com"},
11
11
  ]
12
- requires-python = ">=3.7"
12
+ requires-python = ">=3.10"
13
13
  readme = "README.md"
14
14
  license = {file = "LICENSE.txt"}
15
15
  dependencies = [
@@ -17,10 +17,11 @@ dependencies = [
17
17
  "selenium",
18
18
  "flask",
19
19
  "watchdog",
20
+ "rich",
20
21
  ]
21
22
 
22
23
  [project.scripts]
23
- "compare-html" = "htmlcmp.compare_output:main"
24
+ "compare-html" = "htmlcmp.compare_output_cli:main"
24
25
  "compare-html-server" = "htmlcmp.compare_output_server:main"
25
26
  "html-render-diff" = "htmlcmp.html_render_diff:main"
26
27
  "html-tidy" = "htmlcmp.tidy_output:main"
@@ -0,0 +1,135 @@
1
+ import sys
2
+ import json
3
+ import logging
4
+ import filecmp
5
+ from pathlib import Path
6
+
7
+ from htmlcmp.html_render_diff import get_browser, html_render_diff
8
+
9
+
10
+ class bcolors:
11
+ HEADER = "\033[95m"
12
+ OKBLUE = "\033[94m"
13
+ OKCYAN = "\033[96m"
14
+ OKGREEN = "\033[92m"
15
+ WARNING = "\033[93m"
16
+ FAIL = "\033[91m"
17
+ ENDC = "\033[0m"
18
+ BOLD = "\033[1m"
19
+ UNDERLINE = "\033[4m"
20
+
21
+
22
+ def parse_json(path: Path) -> dict:
23
+ if not isinstance(path, Path):
24
+ raise TypeError(f"Expected Path, got {type(path)}")
25
+ if not path.is_file():
26
+ raise FileNotFoundError(f"File not found: {path}")
27
+
28
+ with open(path) as f:
29
+ return json.load(f)
30
+
31
+
32
+ def compare_json(a: Path, b: Path) -> bool:
33
+ if not isinstance(a, Path) or not isinstance(b, Path):
34
+ raise TypeError("Both arguments must be of type Path")
35
+ if not a.is_file() or not b.is_file():
36
+ raise FileNotFoundError("Both arguments must be files")
37
+
38
+ json_a = json.dumps(parse_json(a), sort_keys=True)
39
+ json_b = json.dumps(parse_json(b), sort_keys=True)
40
+ return json_a == json_b
41
+
42
+
43
+ def compare_html(a: Path, b: Path, browser=None, diff_output: Path = None) -> bool:
44
+ if not isinstance(a, Path) or not isinstance(b, Path):
45
+ raise TypeError("Both arguments must be of type Path")
46
+ if not a.is_file() or not b.is_file():
47
+ raise FileNotFoundError("Both arguments must be files")
48
+
49
+ if browser is None:
50
+ browser = get_browser("firefox")
51
+ diff, (image_a, image_b) = html_render_diff(a, b, browser=browser)
52
+ result = diff.getbbox() is None
53
+ if diff_output is not None and not result:
54
+ diff_output.mkdir(parents=True, exist_ok=True)
55
+ image_a.save(diff_output / "a.png")
56
+ image_b.save(diff_output / "b.png")
57
+ diff.save(diff_output / "diff.png")
58
+ return result
59
+
60
+
61
+ def compare_files(a: Path, b: Path, **kwargs) -> bool:
62
+ if not isinstance(a, Path) or not isinstance(b, Path):
63
+ raise TypeError("Both arguments must be of type Path")
64
+ if not a.is_file() or not b.is_file():
65
+ raise FileNotFoundError("Both arguments must be files")
66
+
67
+ if filecmp.cmp(a, b, shallow=False):
68
+ return True
69
+ if a.suffix == ".json":
70
+ return compare_json(a, b)
71
+ if a.suffix == ".html":
72
+ if kwargs.get("browser") is None:
73
+ # No browser available (e.g. --driver none): the files already
74
+ # differ at the byte level and we cannot render to check for visual
75
+ # equality, so report them as different rather than spinning up a
76
+ # browser here.
77
+ return False
78
+ return compare_html(a, b, **kwargs)
79
+ return False
80
+
81
+
82
+ def comparable_file(path: Path) -> bool:
83
+ if not isinstance(path, Path):
84
+ raise TypeError(f"Expected Path, got {type(path)}")
85
+ if not path.is_file():
86
+ raise FileNotFoundError(f"File not found: {path}")
87
+
88
+ if path.suffix == ".json":
89
+ return True
90
+ if path.suffix == ".html":
91
+ return True
92
+ return False
93
+
94
+
95
+ def verbosity_to_level(verbosity: int) -> int:
96
+ if verbosity >= 3:
97
+ return logging.DEBUG
98
+ elif verbosity == 2:
99
+ return logging.INFO
100
+ elif verbosity == 1:
101
+ return logging.WARNING
102
+ else:
103
+ return logging.ERROR
104
+
105
+
106
+ def setup_logging(
107
+ verbosity: int, log_file: Path = None, log_file_verbosity: int = None
108
+ ) -> None:
109
+ level = verbosity_to_level(verbosity)
110
+
111
+ root_logger = logging.getLogger()
112
+ root_logger.setLevel(level)
113
+ root_logger.handlers.clear()
114
+
115
+ formatter = logging.Formatter(
116
+ fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
117
+ datefmt="%Y-%m-%d %H:%M:%S",
118
+ )
119
+
120
+ console_handler = logging.StreamHandler(sys.stderr)
121
+ console_handler.setLevel(level)
122
+ console_handler.setFormatter(formatter)
123
+ root_logger.addHandler(console_handler)
124
+
125
+ if log_file is not None:
126
+ file_level = (
127
+ verbosity_to_level(log_file_verbosity)
128
+ if log_file_verbosity is not None
129
+ else level
130
+ )
131
+
132
+ file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
133
+ file_handler.setLevel(file_level)
134
+ file_handler.setFormatter(formatter)
135
+ root_logger.addHandler(file_handler)
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import sys
6
+ import logging
7
+ import argparse
8
+ import threading
9
+ from pathlib import Path
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.progress import (
15
+ Progress,
16
+ SpinnerColumn,
17
+ BarColumn,
18
+ TextColumn,
19
+ MofNCompleteColumn,
20
+ TimeElapsedColumn,
21
+ TimeRemainingColumn,
22
+ )
23
+
24
+ from htmlcmp.html_render_diff import get_browser
25
+ from htmlcmp.common import comparable_file, compare_files, setup_logging
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class Config:
31
+ thread_local = threading.local()
32
+
33
+
34
+ class Task:
35
+ """A single file comparison between the reference (A) and monitored (B) tree."""
36
+
37
+ def __init__(self, rel: Path, a: Path, b: Path, diff_output: Path = None):
38
+ self.rel = rel
39
+ self.a = a
40
+ self.b = b
41
+ self.diff_output = diff_output
42
+
43
+
44
+ class Failure:
45
+ """A file that differs, is missing on one side, or errored while comparing.
46
+
47
+ ``kind`` is one of "different", "missing", or "error".
48
+ """
49
+
50
+ def __init__(self, rel: Path, kind: str, reason: str):
51
+ self.rel = rel
52
+ self.kind = kind
53
+ self.reason = reason
54
+
55
+
56
+ def collect_tasks(
57
+ a: Path, b: Path, root: Path = None, diff_output: Path = None
58
+ ) -> tuple[list[Task], list[Failure]]:
59
+ """Walk both trees once and return (comparable tasks, structural failures).
60
+
61
+ Structural failures are files/directories present on only one side; they are
62
+ known to differ up-front and never get submitted to the executor.
63
+ """
64
+ if not isinstance(a, Path) or not isinstance(b, Path):
65
+ raise TypeError("Both arguments must be of type Path")
66
+ if not a.is_dir() or not b.is_dir():
67
+ raise FileNotFoundError("Both arguments must be directories")
68
+
69
+ if root is None:
70
+ root = a
71
+
72
+ tasks: list[Task] = []
73
+ failures: list[Failure] = []
74
+
75
+ left = sorted(p.name for p in a.iterdir())
76
+ right = sorted(p.name for p in b.iterdir())
77
+
78
+ left_files = {
79
+ name for name in left if (a / name).is_file() and comparable_file(a / name)
80
+ }
81
+ right_files = {
82
+ name for name in right if (b / name).is_file() and comparable_file(b / name)
83
+ }
84
+
85
+ for name in sorted(left_files | right_files):
86
+ rel = (a / name).relative_to(root)
87
+ if name in left_files and name in right_files:
88
+ tasks.append(
89
+ Task(
90
+ rel,
91
+ a / name,
92
+ b / name,
93
+ None if diff_output is None else diff_output / name,
94
+ )
95
+ )
96
+ elif name in left_files:
97
+ failures.append(Failure(rel, "missing", "missing in monitored (B)"))
98
+ else:
99
+ failures.append(Failure(rel, "missing", "missing in reference (A)"))
100
+
101
+ left_dirs = {name for name in left if (a / name).is_dir()}
102
+ right_dirs = {name for name in right if (b / name).is_dir()}
103
+
104
+ for name in sorted(left_dirs & right_dirs):
105
+ sub_tasks, sub_failures = collect_tasks(
106
+ a / name,
107
+ b / name,
108
+ root=root,
109
+ diff_output=None if diff_output is None else diff_output / name,
110
+ )
111
+ tasks.extend(sub_tasks)
112
+ failures.extend(sub_failures)
113
+
114
+ for name in sorted(left_dirs - right_dirs):
115
+ failures.append(
116
+ Failure((a / name).relative_to(root), "missing", "missing in monitored (B)")
117
+ )
118
+ for name in sorted(right_dirs - left_dirs):
119
+ failures.append(
120
+ Failure((b / name).relative_to(root), "missing", "missing in reference (A)")
121
+ )
122
+
123
+ return tasks, failures
124
+
125
+
126
+ def run_task(task: Task) -> bool:
127
+ logger.debug("Comparing %s", task.rel)
128
+ browser = getattr(Config.thread_local, "browser", None)
129
+ return compare_files(task.a, task.b, browser=browser, diff_output=task.diff_output)
130
+
131
+
132
+ def make_executor(max_workers: int, driver: str | None) -> ThreadPoolExecutor:
133
+ def initializer():
134
+ # One browser per worker thread. Without a driver we only ever compare
135
+ # JSON / byte-identical files, so no browser is needed.
136
+ if driver is not None:
137
+ if getattr(Config.thread_local, "browser", None) is None:
138
+ logger.info("Starting %s browser for worker thread", driver)
139
+ Config.thread_local.browser = get_browser(driver=driver)
140
+
141
+ logger.info("Creating executor with %d worker(s)", max_workers)
142
+ return ThreadPoolExecutor(max_workers=max_workers, initializer=initializer)
143
+
144
+
145
+ def github_error(rel: Path, reason: str) -> None:
146
+ """Emit a GitHub Actions error annotation so failures surface in the UI."""
147
+ # https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions
148
+ print(f"::error file={rel}::{reason}")
149
+
150
+
151
+ def _resolve(task: Task, future, failures: list[Failure], github: bool):
152
+ """Resolve a finished future into an optional Failure. Returns it or None."""
153
+ try:
154
+ same = future.result()
155
+ except Exception as exc: # noqa: BLE001 - surface any comparison error as a failure
156
+ logger.exception("Error comparing %s", task.rel)
157
+ failure = Failure(task.rel, "error", f"error: {exc}")
158
+ else:
159
+ if same:
160
+ logger.debug("Match: %s", task.rel)
161
+ return None
162
+ logger.info("Difference: %s", task.rel)
163
+ failure = Failure(task.rel, "different", "different")
164
+
165
+ failures.append(failure)
166
+ if github:
167
+ github_error(failure.rel, failure.reason)
168
+ return failure
169
+
170
+
171
+ def _run_live(future_to_task, console, failures, github):
172
+ progress = Progress(
173
+ SpinnerColumn(),
174
+ TextColumn("[progress.description]{task.description}"),
175
+ BarColumn(),
176
+ MofNCompleteColumn(),
177
+ TimeElapsedColumn(),
178
+ TimeRemainingColumn(),
179
+ console=console,
180
+ transient=True,
181
+ )
182
+ with progress:
183
+ bar = progress.add_task("comparing…", total=len(future_to_task))
184
+ for future in as_completed(future_to_task):
185
+ task = future_to_task[future]
186
+ # Transient line shows what's flowing through; only failures persist.
187
+ progress.update(bar, description=str(task.rel))
188
+ failure = _resolve(task, future, failures, github)
189
+ if failure is not None:
190
+ progress.console.print(
191
+ f"[red]✘[/red] {escape(str(failure.rel))} "
192
+ f"[red]— {escape(failure.reason)}[/red]"
193
+ )
194
+ progress.advance(bar)
195
+
196
+
197
+ def _run_plain(future_to_task, console, failures, github):
198
+ # No live region in CI / non-TTY: print failures as they happen plus a
199
+ # periodic heartbeat so long runs still show they're alive.
200
+ total = len(future_to_task)
201
+ step = max(1, total // 20)
202
+ done = 0
203
+ for future in as_completed(future_to_task):
204
+ task = future_to_task[future]
205
+ failure = _resolve(task, future, failures, github)
206
+ if failure is not None:
207
+ console.print(
208
+ f"[red]✘[/red] {escape(str(failure.rel))} "
209
+ f"[red]— {escape(failure.reason)}[/red]"
210
+ )
211
+ done += 1
212
+ if done % step == 0 or done == total:
213
+ console.print(f"[dim] … {done}/{total} compared[/dim]")
214
+
215
+
216
+ def _print_summary(console, total: int, failures: list[Failure]) -> None:
217
+ console.rule("[bold]Summary")
218
+
219
+ n_diff = sum(1 for f in failures if f.kind == "different")
220
+ n_error = sum(1 for f in failures if f.kind == "error")
221
+ n_missing = sum(1 for f in failures if f.kind == "missing")
222
+ matched = total - n_diff - n_error
223
+
224
+ if not failures:
225
+ console.print(f"[green]✓ All {total} file(s) match.[/green]")
226
+ return
227
+
228
+ parts = [f"[green]{matched} matched[/green]"]
229
+ if n_diff:
230
+ parts.append(f"[red]{n_diff} different[/red]")
231
+ if n_missing:
232
+ parts.append(f"[red]{n_missing} missing[/red]")
233
+ if n_error:
234
+ parts.append(f"[red]{n_error} error[/red]")
235
+ console.print(", ".join(parts))
236
+
237
+ console.print("\n[red bold]Failures:[/red bold]")
238
+ for f in sorted(failures, key=lambda f: (f.kind, str(f.rel))):
239
+ console.print(
240
+ f" [red]{escape(str(f.rel))}[/red] [dim]— {escape(f.reason)}[/dim]"
241
+ )
242
+
243
+
244
+ def run(
245
+ a: Path,
246
+ b: Path,
247
+ *,
248
+ driver: str | None,
249
+ max_workers: int,
250
+ diff_output: Path | None,
251
+ console: Console,
252
+ live: bool,
253
+ github: bool,
254
+ ) -> int:
255
+ console.print(
256
+ f"[bold]Comparing[/bold] {escape(str(a))} [dim]→[/dim] {escape(str(b))}"
257
+ )
258
+
259
+ tasks, failures = collect_tasks(a, b, diff_output=diff_output)
260
+ logger.info(
261
+ "Collected %d comparable file(s), %d structural difference(s)",
262
+ len(tasks),
263
+ len(failures),
264
+ )
265
+
266
+ # Report structural failures (missing files/dirs) up-front.
267
+ for f in sorted(failures, key=lambda f: str(f.rel)):
268
+ console.print(
269
+ f"[red]✘[/red] {escape(str(f.rel))} [red]— {escape(f.reason)}[/red]"
270
+ )
271
+ if github:
272
+ github_error(f.rel, f.reason)
273
+
274
+ total = len(tasks)
275
+ if total == 0:
276
+ console.print("[dim]No comparable files to compare.[/dim]")
277
+ else:
278
+ executor = make_executor(max_workers, driver)
279
+ try:
280
+ future_to_task = {executor.submit(run_task, t): t for t in tasks}
281
+ if live:
282
+ _run_live(future_to_task, console, failures, github)
283
+ else:
284
+ _run_plain(future_to_task, console, failures, github)
285
+ finally:
286
+ executor.shutdown(wait=True)
287
+
288
+ _print_summary(console, total, failures)
289
+
290
+ return 1 if failures else 0
291
+
292
+
293
+ def main():
294
+ parser = argparse.ArgumentParser(
295
+ prog="compare-html",
296
+ description="Compare two directory trees of rendered HTML/JSON files.",
297
+ )
298
+ parser.add_argument("a", type=Path, help="Reference directory (A)")
299
+ parser.add_argument("b", type=Path, help="Monitored directory (B)")
300
+ parser.add_argument(
301
+ "--driver",
302
+ choices=["chrome", "firefox", "none"],
303
+ default="firefox",
304
+ help="Browser used to render HTML diffs; 'none' compares only "
305
+ "JSON and byte-identical files (default: firefox)",
306
+ )
307
+ parser.add_argument(
308
+ "--diff-output", type=Path, help="Directory to write diff images for mismatches"
309
+ )
310
+ parser.add_argument(
311
+ "-j",
312
+ "--max-workers",
313
+ type=int,
314
+ default=1,
315
+ help="Number of parallel comparison workers (default: 1)",
316
+ )
317
+ parser.add_argument(
318
+ "--no-progress",
319
+ action="store_true",
320
+ help="Disable the live progress bar (forced off when not a TTY / in CI)",
321
+ )
322
+ parser.add_argument(
323
+ "-v",
324
+ "--verbose",
325
+ action="count",
326
+ default=0,
327
+ help="Increase verbosity (-v, -vv, -vvv)",
328
+ )
329
+ parser.add_argument("--log-file", type=Path, help="Path to log file")
330
+ parser.add_argument(
331
+ "--log-file-verbosity", type=int, help="Log file verbosity level"
332
+ )
333
+ args = parser.parse_args()
334
+
335
+ setup_logging(args.verbose, args.log_file, args.log_file_verbosity)
336
+
337
+ if not args.a.is_dir() or not args.b.is_dir():
338
+ print(f"Both arguments must be directories: {args.a} {args.b}", file=sys.stderr)
339
+ return 2
340
+
341
+ driver = None if args.driver == "none" else args.driver
342
+
343
+ console = Console()
344
+ github = os.environ.get("GITHUB_ACTIONS") == "true"
345
+ in_ci = bool(os.environ.get("CI"))
346
+ live = console.is_terminal and not in_ci and not args.no_progress
347
+
348
+ return run(
349
+ args.a,
350
+ args.b,
351
+ driver=driver,
352
+ max_workers=args.max_workers,
353
+ diff_output=args.diff_output,
354
+ console=console,
355
+ live=live,
356
+ github=github,
357
+ )
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())
@@ -15,7 +15,11 @@ from flask import Flask, send_from_directory, send_file
15
15
  import watchdog.observers
16
16
  import watchdog.events
17
17
 
18
- from htmlcmp.compare_output import comparable_file, compare_files
18
+ from htmlcmp.common import (
19
+ comparable_file,
20
+ compare_files,
21
+ setup_logging,
22
+ )
19
23
  from htmlcmp.html_render_diff import get_browser, html_render_diff
20
24
 
21
25
  logger = logging.getLogger(__name__)
@@ -28,6 +32,10 @@ class Config:
28
32
  observer = None
29
33
  comparator = None
30
34
  browser = None
35
+ # Serializes access to the single shared ``browser`` above: Flask serves
36
+ # requests from a thread pool and Selenium drivers are not thread-safe, so
37
+ # concurrent /image_diff requests would otherwise interleave on one driver.
38
+ browser_lock = threading.Lock()
31
39
  thread_local = threading.local()
32
40
  log_file: Path = None
33
41
 
@@ -718,11 +726,12 @@ def image_diff(path: str):
718
726
  if not (Config.path_a / path).is_file() or not (Config.path_b / path).is_file():
719
727
  return "Image diff not available: file missing on one side", 404
720
728
 
721
- diff, _ = html_render_diff(
722
- Config.path_a / path,
723
- Config.path_b / path,
724
- Config.browser,
725
- )
729
+ with Config.browser_lock:
730
+ diff, _ = html_render_diff(
731
+ Config.path_a / path,
732
+ Config.path_b / path,
733
+ Config.browser,
734
+ )
726
735
  tmp = io.BytesIO()
727
736
  diff.save(tmp, "JPEG", quality=70)
728
737
  tmp.seek(0)
@@ -775,49 +784,6 @@ def update_ref(path: str):
775
784
  return "Reference updated", 200
776
785
 
777
786
 
778
- def verbosity_to_level(verbosity: int) -> int:
779
- if verbosity >= 3:
780
- return logging.DEBUG
781
- elif verbosity == 2:
782
- return logging.INFO
783
- elif verbosity == 1:
784
- return logging.WARNING
785
- else:
786
- return logging.ERROR
787
-
788
-
789
- def setup_logging(
790
- verbosity: int, log_file: Path = None, log_file_verbosity: int = None
791
- ) -> None:
792
- level = verbosity_to_level(verbosity)
793
-
794
- root_logger = logging.getLogger()
795
- root_logger.setLevel(level)
796
- root_logger.handlers.clear()
797
-
798
- formatter = logging.Formatter(
799
- fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
800
- datefmt="%Y-%m-%d %H:%M:%S",
801
- )
802
-
803
- console_handler = logging.StreamHandler(sys.stderr)
804
- console_handler.setLevel(level)
805
- console_handler.setFormatter(formatter)
806
- root_logger.addHandler(console_handler)
807
-
808
- if log_file is not None:
809
- file_level = (
810
- verbosity_to_level(log_file_verbosity)
811
- if log_file_verbosity is not None
812
- else level
813
- )
814
-
815
- file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
816
- file_handler.setLevel(file_level)
817
- file_handler.setFormatter(formatter)
818
- root_logger.addHandler(file_handler)
819
-
820
-
821
787
  def main():
822
788
  parser = argparse.ArgumentParser()
823
789
  parser.add_argument("ref", type=Path, help="Path to the reference directory")
@@ -825,6 +791,11 @@ def main():
825
791
  parser.add_argument("--driver", choices=["chrome", "firefox"])
826
792
  parser.add_argument("--max-workers", type=int, default=1)
827
793
  parser.add_argument("--compare", action="store_true")
794
+ parser.add_argument(
795
+ "--host",
796
+ default="0.0.0.0",
797
+ help="Host/interface to bind the server to (default: 0.0.0.0)",
798
+ )
828
799
  parser.add_argument("--port", type=int, default=5000)
829
800
  parser.add_argument(
830
801
  "-v",
@@ -859,7 +830,7 @@ def main():
859
830
  Config.observer = Observer()
860
831
  Config.observer.start()
861
832
 
862
- app.run(host="0.0.0.0", port=args.port)
833
+ app.run(host=args.host, port=args.port)
863
834
 
864
835
  if args.compare:
865
836
  Config.observer.stop()
@@ -108,8 +108,8 @@ def main():
108
108
  parser.add_argument("a", type=Path, help="Path to the first HTML file")
109
109
  parser.add_argument("b", type=Path, help="Path to the second HTML file")
110
110
  parser.add_argument("--driver", choices=["chrome", "firefox"], default="firefox")
111
- parser.add_argument("--max-width", default=1000)
112
- parser.add_argument("--max-height", default=10000)
111
+ parser.add_argument("--max-width", type=int, default=1000)
112
+ parser.add_argument("--max-height", type=int, default=10000)
113
113
  args = parser.parse_args()
114
114
 
115
115
  browser = get_browser(args.driver, args.max_width, args.max_height)