htmlcmp 1.3.0__tar.gz → 2.0.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.
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/PKG-INFO +3 -2
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/pyproject.toml +4 -3
- htmlcmp-2.0.0/src/htmlcmp/common.py +135 -0
- htmlcmp-2.0.0/src/htmlcmp/compare_output_cli.py +361 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp/compare_output_server.py +21 -50
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp/html_render_diff.py +2 -2
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp/tidy_output.py +8 -8
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/PKG-INFO +3 -2
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/SOURCES.txt +1 -1
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/entry_points.txt +1 -1
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/requires.txt +1 -0
- htmlcmp-1.3.0/src/htmlcmp/common.py +0 -10
- htmlcmp-1.3.0/src/htmlcmp/compare_output.py +0 -277
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/README.md +0 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/setup.cfg +0 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp/__init__.py +0 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/dependency_links.txt +0 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/src/htmlcmp.egg-info/top_level.txt +0 -0
- {htmlcmp-1.3.0 → htmlcmp-2.0.0}/tests/test_html_render_diff.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlcmp
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.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.
|
|
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 = "
|
|
3
|
+
version = "2.0.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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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=
|
|
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)
|
|
@@ -112,12 +112,12 @@ def tidy_dir(
|
|
|
112
112
|
"error": [],
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
items =
|
|
116
|
-
files = sorted(
|
|
117
|
-
dirs = sorted(
|
|
115
|
+
items = list(path.iterdir())
|
|
116
|
+
files = sorted(p for p in items if p.is_file() and tidyable_file(p))
|
|
117
|
+
dirs = sorted(p for p in items if p.is_dir())
|
|
118
118
|
|
|
119
|
-
for
|
|
120
|
-
|
|
119
|
+
for filepath in files:
|
|
120
|
+
filename = filepath.name
|
|
121
121
|
tidy = tidy_file(filepath, html_tidy_config=html_tidy_config, verbose=verbose)
|
|
122
122
|
if tidy == 0:
|
|
123
123
|
print(f"{prefix_file}{bcolors.OKGREEN}{filename} ✓{bcolors.ENDC}")
|
|
@@ -128,10 +128,10 @@ def tidy_dir(
|
|
|
128
128
|
print(f"{prefix_file}{bcolors.FAIL}{filename} ✘{bcolors.ENDC}")
|
|
129
129
|
result["error"].append(filepath)
|
|
130
130
|
|
|
131
|
-
for
|
|
132
|
-
print(prefix + "├── " +
|
|
131
|
+
for dirpath in dirs:
|
|
132
|
+
print(prefix + "├── " + dirpath.name)
|
|
133
133
|
subresult = tidy_dir(
|
|
134
|
-
|
|
134
|
+
dirpath,
|
|
135
135
|
level=level + 1,
|
|
136
136
|
prefix=prefix + "│ ",
|
|
137
137
|
html_tidy_config=html_tidy_config,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlcmp
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.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.
|
|
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,277 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
|
|
4
|
-
import sys
|
|
5
|
-
import argparse
|
|
6
|
-
import json
|
|
7
|
-
import threading
|
|
8
|
-
import filecmp
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
-
|
|
12
|
-
from htmlcmp.html_render_diff import get_browser, html_render_diff
|
|
13
|
-
from htmlcmp.common import bcolors
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class Config:
|
|
17
|
-
thread_local = threading.local()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def parse_json(path: Path) -> dict:
|
|
21
|
-
if not isinstance(path, Path):
|
|
22
|
-
raise TypeError(f"Expected Path, got {type(path)}")
|
|
23
|
-
if not path.is_file():
|
|
24
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
25
|
-
|
|
26
|
-
with open(path) as f:
|
|
27
|
-
return json.load(f)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def compare_json(a: Path, b: Path) -> bool:
|
|
31
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
32
|
-
raise TypeError("Both arguments must be of type Path")
|
|
33
|
-
if not a.is_file() or not b.is_file():
|
|
34
|
-
raise FileNotFoundError("Both arguments must be files")
|
|
35
|
-
|
|
36
|
-
json_a = json.dumps(parse_json(a), sort_keys=True)
|
|
37
|
-
json_b = json.dumps(parse_json(b), sort_keys=True)
|
|
38
|
-
return json_a == json_b
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def compare_html(a: Path, b: Path, browser=None, diff_output: Path = None) -> bool:
|
|
42
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
43
|
-
raise TypeError("Both arguments must be of type Path")
|
|
44
|
-
if not a.is_file() or not b.is_file():
|
|
45
|
-
raise FileNotFoundError("Both arguments must be files")
|
|
46
|
-
|
|
47
|
-
if browser is None:
|
|
48
|
-
browser = get_browser()
|
|
49
|
-
diff, (image_a, image_b) = html_render_diff(a, b, browser=browser)
|
|
50
|
-
result = True if diff.getbbox() is None else False
|
|
51
|
-
if diff_output is not None and not result:
|
|
52
|
-
diff_output.mkdir(parents=True, exist_ok=True)
|
|
53
|
-
image_a.save(diff_output / "a.png")
|
|
54
|
-
image_b.save(diff_output / "b.png")
|
|
55
|
-
diff.save(diff_output / "diff.png")
|
|
56
|
-
return result
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def compare_files(a: Path, b: Path, **kwargs) -> bool:
|
|
60
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
61
|
-
raise TypeError("Both arguments must be of type Path")
|
|
62
|
-
if not a.is_file() or not b.is_file():
|
|
63
|
-
raise FileNotFoundError("Both arguments must be files")
|
|
64
|
-
|
|
65
|
-
if filecmp.cmp(a, b):
|
|
66
|
-
return True
|
|
67
|
-
if a.suffix == ".json":
|
|
68
|
-
return compare_json(a, b)
|
|
69
|
-
if a.suffix == ".html":
|
|
70
|
-
return compare_html(a, b, **kwargs)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def comparable_file(path: Path) -> bool:
|
|
74
|
-
if not isinstance(path, Path):
|
|
75
|
-
raise TypeError(f"Expected Path, got {type(path)}")
|
|
76
|
-
if not path.is_file():
|
|
77
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
78
|
-
|
|
79
|
-
if path.suffix == ".json":
|
|
80
|
-
return True
|
|
81
|
-
if path.suffix == ".html":
|
|
82
|
-
return True
|
|
83
|
-
return False
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def submit_compare_dirs(
|
|
87
|
-
a: Path, b: Path, executor, diff_output: Path = None, **kwargs
|
|
88
|
-
) -> dict[str, list[Path]]:
|
|
89
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
90
|
-
raise TypeError("Both arguments must be of type Path")
|
|
91
|
-
if not a.is_dir() or not b.is_dir():
|
|
92
|
-
raise FileNotFoundError("Both arguments must be directories")
|
|
93
|
-
|
|
94
|
-
results = {
|
|
95
|
-
"common_dirs": [],
|
|
96
|
-
"common_files": [],
|
|
97
|
-
"left_files_missing": [],
|
|
98
|
-
"right_files_missing": [],
|
|
99
|
-
"left_dirs_missing": [],
|
|
100
|
-
"right_dirs_missing": [],
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
left = sorted(p.name for p in a.iterdir())
|
|
104
|
-
right = sorted(p.name for p in b.iterdir())
|
|
105
|
-
|
|
106
|
-
left_files = sorted(
|
|
107
|
-
[name for name in left if (a / name).is_file() and comparable_file(a / name)]
|
|
108
|
-
)
|
|
109
|
-
right_files = sorted(
|
|
110
|
-
[name for name in right if (b / name).is_file() and comparable_file(b / name)]
|
|
111
|
-
)
|
|
112
|
-
common_files = [name for name in left_files if name in right_files]
|
|
113
|
-
|
|
114
|
-
def compare(path_a, path_b, diff_output):
|
|
115
|
-
browser = getattr(Config.thread_local, "browser", None)
|
|
116
|
-
return compare_files(path_a, path_b, browser=browser, diff_output=diff_output)
|
|
117
|
-
|
|
118
|
-
for name in common_files:
|
|
119
|
-
future = executor.submit(
|
|
120
|
-
compare,
|
|
121
|
-
a / name,
|
|
122
|
-
b / name,
|
|
123
|
-
diff_output=(None if diff_output is None else diff_output / name),
|
|
124
|
-
)
|
|
125
|
-
results["common_files"].append((name, future))
|
|
126
|
-
|
|
127
|
-
results["left_files_missing"] = [
|
|
128
|
-
name for name in right_files if name not in left_files
|
|
129
|
-
]
|
|
130
|
-
results["right_files_missing"] = [
|
|
131
|
-
name for name in left_files if name not in right_files
|
|
132
|
-
]
|
|
133
|
-
|
|
134
|
-
left_dirs = sorted([name for name in left if (a / name).is_dir()])
|
|
135
|
-
right_dirs = sorted([name for name in right if (b / name).is_dir()])
|
|
136
|
-
common_dirs = [path for path in left_dirs if path in right_dirs]
|
|
137
|
-
|
|
138
|
-
for name in common_dirs:
|
|
139
|
-
sub_results = submit_compare_dirs(
|
|
140
|
-
a / name,
|
|
141
|
-
b / name,
|
|
142
|
-
executor=executor,
|
|
143
|
-
diff_output=(None if diff_output is None else diff_output / name),
|
|
144
|
-
**kwargs,
|
|
145
|
-
)
|
|
146
|
-
results["common_dirs"].append((name, sub_results))
|
|
147
|
-
|
|
148
|
-
results["left_dirs_missing"] = [
|
|
149
|
-
name for name in right_dirs if name not in left_dirs
|
|
150
|
-
]
|
|
151
|
-
results["right_dirs_missing"] = [
|
|
152
|
-
name for name in left_dirs if name not in right_dirs
|
|
153
|
-
]
|
|
154
|
-
|
|
155
|
-
return results
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def print_results(
|
|
159
|
-
results: dict[str, list[Path]], a: Path, b: Path, level: int = 0, prefix: str = ""
|
|
160
|
-
) -> dict[str, list[Path]]:
|
|
161
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
162
|
-
raise TypeError("Both arguments must be of type Path")
|
|
163
|
-
if not a.is_dir() or not b.is_dir():
|
|
164
|
-
raise FileNotFoundError("Both arguments must be directories")
|
|
165
|
-
if not isinstance(results, dict):
|
|
166
|
-
raise TypeError("Results must be a dictionary")
|
|
167
|
-
|
|
168
|
-
prefix_file = prefix + "├── "
|
|
169
|
-
if level == 0:
|
|
170
|
-
print(f"compare dir {a} with {b}")
|
|
171
|
-
|
|
172
|
-
result = {
|
|
173
|
-
"left_files_missing": [],
|
|
174
|
-
"right_files_missing": [],
|
|
175
|
-
"left_dirs_missing": [],
|
|
176
|
-
"right_dirs_missing": [],
|
|
177
|
-
"files_different": [],
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
left_files_missing = " ".join(results["left_files_missing"])
|
|
181
|
-
if left_files_missing:
|
|
182
|
-
print(
|
|
183
|
-
f"{prefix_file}{bcolors.FAIL}missing files left: {left_files_missing} ✘{bcolors.ENDC}"
|
|
184
|
-
)
|
|
185
|
-
result["left_files_missing"].extend(
|
|
186
|
-
[a / name for name in results["left_files_missing"]]
|
|
187
|
-
)
|
|
188
|
-
right_files_missing = " ".join(results["right_files_missing"])
|
|
189
|
-
if right_files_missing:
|
|
190
|
-
print(
|
|
191
|
-
f"{prefix_file}{bcolors.FAIL}missing files right: {right_files_missing} ✘{bcolors.ENDC}"
|
|
192
|
-
)
|
|
193
|
-
result["right_files_missing"].extend(
|
|
194
|
-
[a / name for name in results["right_files_missing"]]
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
for name, future in results["common_files"]:
|
|
198
|
-
cmp = future.result()
|
|
199
|
-
if cmp:
|
|
200
|
-
print(f"{prefix_file}{bcolors.OKGREEN}{name} ✓{bcolors.ENDC}")
|
|
201
|
-
else:
|
|
202
|
-
print(f"{prefix_file}{bcolors.FAIL}{name} ✘{bcolors.ENDC}")
|
|
203
|
-
result["files_different"].append(a / name)
|
|
204
|
-
|
|
205
|
-
left_dirs_missing = " ".join(results["left_dirs_missing"])
|
|
206
|
-
if left_dirs_missing:
|
|
207
|
-
print(
|
|
208
|
-
f"{prefix_file}{bcolors.FAIL}missing dirs left: {left_dirs_missing} ✘{bcolors.ENDC}"
|
|
209
|
-
)
|
|
210
|
-
result["left_dirs_missing"].extend(
|
|
211
|
-
[a / name for name in results["left_dirs_missing"]]
|
|
212
|
-
)
|
|
213
|
-
right_dirs_missing = " ".join(results["right_dirs_missing"])
|
|
214
|
-
if right_dirs_missing:
|
|
215
|
-
print(
|
|
216
|
-
f"{prefix_file}{bcolors.FAIL}missing dirs right: {right_dirs_missing} ✘{bcolors.ENDC}"
|
|
217
|
-
)
|
|
218
|
-
result["right_dirs_missing"].extend(
|
|
219
|
-
[a / name for name in results["right_dirs_missing"]]
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
for name, sub_results in results["common_dirs"]:
|
|
223
|
-
print(prefix + "├── " + name)
|
|
224
|
-
sub_result = print_results(
|
|
225
|
-
sub_results,
|
|
226
|
-
a / name,
|
|
227
|
-
b / name,
|
|
228
|
-
level=level + 1,
|
|
229
|
-
prefix=prefix + "│ ",
|
|
230
|
-
)
|
|
231
|
-
result["left_files_missing"].extend(sub_result["left_files_missing"])
|
|
232
|
-
result["right_files_missing"].extend(sub_result["right_files_missing"])
|
|
233
|
-
result["left_dirs_missing"].extend(sub_result["left_dirs_missing"])
|
|
234
|
-
result["right_dirs_missing"].extend(sub_result["right_dirs_missing"])
|
|
235
|
-
result["files_different"].extend(sub_result["files_different"])
|
|
236
|
-
|
|
237
|
-
return result
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def main():
|
|
241
|
-
parser = argparse.ArgumentParser()
|
|
242
|
-
parser.add_argument("a", type=Path, help="Path to the first directory")
|
|
243
|
-
parser.add_argument("b", type=Path, help="Path to the second directory")
|
|
244
|
-
parser.add_argument("--driver", choices=["chrome", "firefox"], default="firefox")
|
|
245
|
-
parser.add_argument(
|
|
246
|
-
"--diff-output", type=Path, help="Output directory for diff images"
|
|
247
|
-
)
|
|
248
|
-
parser.add_argument("--max-workers", type=int, default=1)
|
|
249
|
-
args = parser.parse_args()
|
|
250
|
-
|
|
251
|
-
def initializer():
|
|
252
|
-
browser = getattr(Config.thread_local, "browser", None)
|
|
253
|
-
if browser is None:
|
|
254
|
-
browser = get_browser(driver=args.driver)
|
|
255
|
-
Config.thread_local.browser = browser
|
|
256
|
-
|
|
257
|
-
executor = ThreadPoolExecutor(max_workers=args.max_workers, initializer=initializer)
|
|
258
|
-
|
|
259
|
-
results = submit_compare_dirs(
|
|
260
|
-
args.a, args.b, executor=executor, diff_output=args.diff_output
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
result = print_results(results, args.a, args.b)
|
|
264
|
-
if (
|
|
265
|
-
result["left_files_missing"]
|
|
266
|
-
or result["right_files_missing"]
|
|
267
|
-
or result["left_dirs_missing"]
|
|
268
|
-
or result["right_dirs_missing"]
|
|
269
|
-
or result["files_different"]
|
|
270
|
-
):
|
|
271
|
-
return 1
|
|
272
|
-
|
|
273
|
-
return 0
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if __name__ == "__main__":
|
|
277
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|