htmlcmp 1.0.18__tar.gz → 1.1.1__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.0.18 → htmlcmp-1.1.1}/PKG-INFO +13 -1
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/README.md +12 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/pyproject.toml +1 -1
- htmlcmp-1.1.1/src/htmlcmp/compare_output_server.py +734 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/PKG-INFO +13 -1
- htmlcmp-1.0.18/src/htmlcmp/compare_output_server.py +0 -417
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/setup.cfg +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp/__init__.py +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp/common.py +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp/compare_output.py +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp/html_render_diff.py +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp/tidy_output.py +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/SOURCES.txt +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/dependency_links.txt +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/entry_points.txt +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/requires.txt +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/src/htmlcmp.egg-info/top_level.txt +0 -0
- {htmlcmp-1.0.18 → htmlcmp-1.1.1}/tests/test_html_render_diff.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlcmp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: Compare HTML files by rendered output
|
|
5
5
|
Author: Andreas Stefl
|
|
6
6
|
Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
|
|
@@ -53,3 +53,15 @@ docker run -ti \
|
|
|
53
53
|
```bash
|
|
54
54
|
docker build --tag odr_core_test test/docker
|
|
55
55
|
```
|
|
56
|
+
|
|
57
|
+
## Run locally
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
PYTHONPATH=$(pwd)/src:$PYTHONPATH python ./src/htmlcmp/compare_output_server.py \
|
|
61
|
+
/path/to/REFERENCE \
|
|
62
|
+
/path/to/MONITORED \
|
|
63
|
+
--compare \
|
|
64
|
+
--driver firefox \
|
|
65
|
+
--port 8000 \
|
|
66
|
+
-vv
|
|
67
|
+
```
|
|
@@ -35,3 +35,15 @@ docker run -ti \
|
|
|
35
35
|
```bash
|
|
36
36
|
docker build --tag odr_core_test test/docker
|
|
37
37
|
```
|
|
38
|
+
|
|
39
|
+
## Run locally
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
PYTHONPATH=$(pwd)/src:$PYTHONPATH python ./src/htmlcmp/compare_output_server.py \
|
|
43
|
+
/path/to/REFERENCE \
|
|
44
|
+
/path/to/MONITORED \
|
|
45
|
+
--compare \
|
|
46
|
+
--driver firefox \
|
|
47
|
+
--port 8000 \
|
|
48
|
+
-vv
|
|
49
|
+
```
|
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import sys
|
|
6
|
+
import shutil
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import functools
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
|
|
14
|
+
from flask import Flask, send_from_directory, send_file
|
|
15
|
+
import watchdog.observers
|
|
16
|
+
import watchdog.events
|
|
17
|
+
|
|
18
|
+
from htmlcmp.compare_output import comparable_file, compare_files
|
|
19
|
+
from htmlcmp.html_render_diff import get_browser, html_render_diff
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config:
|
|
26
|
+
path_a: Path = None
|
|
27
|
+
path_b: Path = None
|
|
28
|
+
driver: str = None
|
|
29
|
+
observer = None
|
|
30
|
+
comparator = None
|
|
31
|
+
browser = None
|
|
32
|
+
thread_local = threading.local()
|
|
33
|
+
log_file: Path = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def result_symbol(result: str) -> str | None:
|
|
37
|
+
if not isinstance(result, str):
|
|
38
|
+
raise TypeError("Result must be of type str")
|
|
39
|
+
|
|
40
|
+
if result == "pending":
|
|
41
|
+
return "🔄"
|
|
42
|
+
if result == "same":
|
|
43
|
+
return "✔"
|
|
44
|
+
if result == "different":
|
|
45
|
+
return "❌"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def result_css(result: str) -> str | None:
|
|
50
|
+
if not isinstance(result, str):
|
|
51
|
+
raise TypeError("Result must be of type str")
|
|
52
|
+
|
|
53
|
+
if result == "pending":
|
|
54
|
+
return "color:blue;"
|
|
55
|
+
if result == "same":
|
|
56
|
+
return "color:green;"
|
|
57
|
+
if result == "different":
|
|
58
|
+
return "color:red;"
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Observer:
|
|
63
|
+
def __init__(self):
|
|
64
|
+
class Handler(watchdog.events.FileSystemEventHandler):
|
|
65
|
+
def __init__(self, path):
|
|
66
|
+
self._path = path
|
|
67
|
+
|
|
68
|
+
def dispatch(self, event):
|
|
69
|
+
event_type = event.event_type
|
|
70
|
+
src_path = Path(event.src_path)
|
|
71
|
+
|
|
72
|
+
logger.debug(f"Watchdog event: {event_type} {src_path}")
|
|
73
|
+
|
|
74
|
+
if event_type not in ["moved", "deleted", "created", "modified"]:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if src_path.is_file():
|
|
78
|
+
logger.debug(
|
|
79
|
+
f"Submit watchdog file change: {event_type} {src_path}"
|
|
80
|
+
)
|
|
81
|
+
Config.comparator.submit(src_path.relative_to(self._path))
|
|
82
|
+
|
|
83
|
+
logger.info("Create watchdog for paths:")
|
|
84
|
+
logger.info(f" A: {Config.path_a}")
|
|
85
|
+
logger.info(f" B: {Config.path_b}")
|
|
86
|
+
|
|
87
|
+
self._observer = watchdog.observers.Observer()
|
|
88
|
+
self._observer.schedule(Handler(Config.path_a), Config.path_a, recursive=True)
|
|
89
|
+
self._observer.schedule(Handler(Config.path_b), Config.path_b, recursive=True)
|
|
90
|
+
|
|
91
|
+
def start(self) -> None:
|
|
92
|
+
logger.info("Starting watchdog observer")
|
|
93
|
+
self._observer.start()
|
|
94
|
+
|
|
95
|
+
def init_compare(a: Path, b: Path):
|
|
96
|
+
logger.debug(f"Initial compare: {a} vs {b}")
|
|
97
|
+
|
|
98
|
+
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
99
|
+
raise TypeError("Paths must be of type Path")
|
|
100
|
+
if not a.is_dir() or not b.is_dir():
|
|
101
|
+
raise ValueError("Both paths must be directories")
|
|
102
|
+
|
|
103
|
+
common_path = a.relative_to(Config.path_a)
|
|
104
|
+
|
|
105
|
+
left = sorted(p.name for p in a.iterdir())
|
|
106
|
+
right = sorted(p.name for p in b.iterdir())
|
|
107
|
+
|
|
108
|
+
common = [name for name in left if name in right]
|
|
109
|
+
|
|
110
|
+
for name in common:
|
|
111
|
+
if (a / name).is_file() and comparable_file(a / name):
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"Submit initial file comparison: {common_path / name}"
|
|
114
|
+
)
|
|
115
|
+
Config.comparator.submit(common_path / name)
|
|
116
|
+
elif (a / name).is_dir():
|
|
117
|
+
init_compare(a / name, b / name)
|
|
118
|
+
|
|
119
|
+
logger.info("Kick off initial comparison of all files")
|
|
120
|
+
init_compare(Config.path_a, Config.path_b)
|
|
121
|
+
logger.info("Initial comparison submitted")
|
|
122
|
+
|
|
123
|
+
def stop(self) -> None:
|
|
124
|
+
logger.info("Stopping watchdog observer")
|
|
125
|
+
self._observer.stop()
|
|
126
|
+
|
|
127
|
+
def join(self) -> None:
|
|
128
|
+
logger.info("Joining watchdog observer")
|
|
129
|
+
self._observer.join()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Comparator:
|
|
133
|
+
def __init__(self, max_workers: int):
|
|
134
|
+
def initializer():
|
|
135
|
+
if Config.driver is not None:
|
|
136
|
+
browser = getattr(Config.thread_local, "browser", None)
|
|
137
|
+
if browser is None:
|
|
138
|
+
browser = get_browser(driver=Config.driver)
|
|
139
|
+
Config.thread_local.browser = browser
|
|
140
|
+
|
|
141
|
+
logger.info(f"Creating comparator with {max_workers} workers")
|
|
142
|
+
|
|
143
|
+
self._executor = ThreadPoolExecutor(
|
|
144
|
+
max_workers=max_workers, initializer=initializer
|
|
145
|
+
)
|
|
146
|
+
self._result = {}
|
|
147
|
+
self._future = {}
|
|
148
|
+
|
|
149
|
+
def submit(self, path: Path) -> None:
|
|
150
|
+
logger.debug(f"Submitting comparison for path: {path}")
|
|
151
|
+
|
|
152
|
+
if not isinstance(path, Path):
|
|
153
|
+
raise TypeError("Path must be of type Path")
|
|
154
|
+
|
|
155
|
+
if path.suffix.lower() in [".html", ".htm"] and Config.driver is None:
|
|
156
|
+
logger.debug(f"Skipping submission of HTML file without browser: {path}")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if path in self._future:
|
|
160
|
+
try:
|
|
161
|
+
self._future[path].cancel()
|
|
162
|
+
self._future[path].result()
|
|
163
|
+
self._future.pop(path)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
self._result[path] = "pending"
|
|
168
|
+
self._future[path] = self._executor.submit(self.compare, path)
|
|
169
|
+
|
|
170
|
+
def compare(self, path: Path) -> None:
|
|
171
|
+
logger.debug(f"Comparing files for path: {path}")
|
|
172
|
+
|
|
173
|
+
if not isinstance(path, Path):
|
|
174
|
+
raise TypeError("Path must be of type Path")
|
|
175
|
+
if path not in self._future:
|
|
176
|
+
raise RuntimeError("Path not submitted for comparison")
|
|
177
|
+
|
|
178
|
+
browser = getattr(Config.thread_local, "browser", None)
|
|
179
|
+
result = compare_files(
|
|
180
|
+
Config.path_a / path,
|
|
181
|
+
Config.path_b / path,
|
|
182
|
+
browser=browser,
|
|
183
|
+
)
|
|
184
|
+
self._result[path] = "same" if result else "different"
|
|
185
|
+
self._future.pop(path)
|
|
186
|
+
|
|
187
|
+
def result(self, path: Path) -> str | None:
|
|
188
|
+
logger.debug(f"Getting comparison result for path: {path}")
|
|
189
|
+
|
|
190
|
+
if not isinstance(path, Path):
|
|
191
|
+
raise TypeError("Path must be of type Path")
|
|
192
|
+
|
|
193
|
+
if path in self._result:
|
|
194
|
+
return self._result[path]
|
|
195
|
+
|
|
196
|
+
if (Config.path_a / path).is_dir():
|
|
197
|
+
a = Config.path_a / path
|
|
198
|
+
b = Config.path_b / path
|
|
199
|
+
|
|
200
|
+
left = sorted(p.name for p in a.iterdir())
|
|
201
|
+
right = sorted(p.name for p in b.iterdir())
|
|
202
|
+
|
|
203
|
+
left_relevant = sorted(
|
|
204
|
+
[
|
|
205
|
+
name
|
|
206
|
+
for name in left
|
|
207
|
+
if (a / name).is_dir()
|
|
208
|
+
or ((a / name).is_file() and comparable_file(a / name))
|
|
209
|
+
]
|
|
210
|
+
)
|
|
211
|
+
right_relevant = sorted(
|
|
212
|
+
[
|
|
213
|
+
name
|
|
214
|
+
for name in right
|
|
215
|
+
if (b / name).is_dir()
|
|
216
|
+
or ((b / name).is_file() and comparable_file(b / name))
|
|
217
|
+
]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
common = [name for name in left_relevant if name in right_relevant]
|
|
221
|
+
left_missing = [
|
|
222
|
+
name for name in right_relevant if name not in left_relevant
|
|
223
|
+
]
|
|
224
|
+
right_missing = [
|
|
225
|
+
name for name in left_relevant if name not in right_relevant
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
return functools.reduce(
|
|
229
|
+
lambda a, b: (
|
|
230
|
+
None
|
|
231
|
+
if None in (a, b)
|
|
232
|
+
else (
|
|
233
|
+
"pending"
|
|
234
|
+
if "pending" in (a, b)
|
|
235
|
+
else "different" if "different" in (a, b) else "same"
|
|
236
|
+
)
|
|
237
|
+
),
|
|
238
|
+
[self.result(path / name) for name in common]
|
|
239
|
+
+ [
|
|
240
|
+
(
|
|
241
|
+
"different"
|
|
242
|
+
if len(left_missing) + len(right_missing) > 0
|
|
243
|
+
else "same"
|
|
244
|
+
)
|
|
245
|
+
],
|
|
246
|
+
"same",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
logger.debug(f"No comparison result for path: {path}")
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
app = Flask("compare")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.route("/")
|
|
257
|
+
def root():
|
|
258
|
+
logger.debug("Generating root directory listing")
|
|
259
|
+
|
|
260
|
+
current_entry_id = 0
|
|
261
|
+
|
|
262
|
+
def next_entry_id() -> int:
|
|
263
|
+
nonlocal current_entry_id
|
|
264
|
+
entry_id = current_entry_id
|
|
265
|
+
current_entry_id += 1
|
|
266
|
+
return entry_id
|
|
267
|
+
|
|
268
|
+
def generate_entry(
|
|
269
|
+
entry_id: int,
|
|
270
|
+
parrent_id: int | None,
|
|
271
|
+
depth: int,
|
|
272
|
+
is_directory: bool,
|
|
273
|
+
is_comparable: bool,
|
|
274
|
+
path: Path,
|
|
275
|
+
name: str,
|
|
276
|
+
message: str = "",
|
|
277
|
+
cmp_result: str = None,
|
|
278
|
+
) -> str:
|
|
279
|
+
result = ""
|
|
280
|
+
|
|
281
|
+
if cmp_result is None and Config.comparator is not None:
|
|
282
|
+
cmp_result = Config.comparator.result(path)
|
|
283
|
+
is_hidden = (
|
|
284
|
+
Config.comparator is not None
|
|
285
|
+
and Config.comparator.result(path.parent) == "same"
|
|
286
|
+
)
|
|
287
|
+
is_collapsed = cmp_result == "same"
|
|
288
|
+
|
|
289
|
+
hidden_str = "hidden" if is_hidden else ""
|
|
290
|
+
result += f'<tr data-entry-id="{entry_id}" data-parent-id="{"" if parrent_id is None else parrent_id}" data-depth="{depth}" {hidden_str}>'
|
|
291
|
+
|
|
292
|
+
if is_directory:
|
|
293
|
+
result += f'<td><button class="toggle">{"▶" if is_collapsed else "▼"}</button></td>'
|
|
294
|
+
else:
|
|
295
|
+
result += "<td></td>"
|
|
296
|
+
|
|
297
|
+
if is_comparable:
|
|
298
|
+
main = f'<a href="/compare/{path}" target="_blank">{name}</a>'
|
|
299
|
+
else:
|
|
300
|
+
main = f"{name}"
|
|
301
|
+
if cmp_result is not None:
|
|
302
|
+
status = result_symbol(cmp_result)
|
|
303
|
+
style = result_css(cmp_result)
|
|
304
|
+
result += f'<td style="{style}">{status}</td>'
|
|
305
|
+
else:
|
|
306
|
+
style = ""
|
|
307
|
+
result += f"<td></td>"
|
|
308
|
+
result += f'<td class="main" style="{style}">{main}</td>'
|
|
309
|
+
result += f"<td>{message}</td>"
|
|
310
|
+
|
|
311
|
+
result += (
|
|
312
|
+
f"<td><button onclick=\"updateRef('{path}')\">update ref</button></td>"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
result += "</tr>"
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def generate_tree(
|
|
320
|
+
a: Path, b: Path, name: str, parrent_id: int | None, depth: int
|
|
321
|
+
) -> str:
|
|
322
|
+
result = ""
|
|
323
|
+
|
|
324
|
+
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
325
|
+
raise TypeError("Paths must be of type Path")
|
|
326
|
+
if not a.is_dir() or not b.is_dir():
|
|
327
|
+
raise ValueError("Both paths must be directories")
|
|
328
|
+
|
|
329
|
+
common_path = a.relative_to(Config.path_a)
|
|
330
|
+
|
|
331
|
+
directory_id = next_entry_id()
|
|
332
|
+
result += generate_entry(
|
|
333
|
+
directory_id,
|
|
334
|
+
parrent_id,
|
|
335
|
+
depth,
|
|
336
|
+
True,
|
|
337
|
+
False,
|
|
338
|
+
common_path,
|
|
339
|
+
name + "/",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
left = sorted(p.name for p in a.iterdir())
|
|
343
|
+
right = sorted(p.name for p in b.iterdir())
|
|
344
|
+
|
|
345
|
+
left_files = sorted(
|
|
346
|
+
[
|
|
347
|
+
name
|
|
348
|
+
for name in left
|
|
349
|
+
if (a / name).is_file() and comparable_file(a / name)
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
right_files = sorted(
|
|
353
|
+
[
|
|
354
|
+
name
|
|
355
|
+
for name in right
|
|
356
|
+
if (b / name).is_file() and comparable_file(b / name)
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
left_dirs = sorted([name for name in left if (a / name).is_dir()])
|
|
361
|
+
right_dirs = sorted([name for name in right if (b / name).is_dir()])
|
|
362
|
+
|
|
363
|
+
common_files = [name for name in left_files if name in right_files]
|
|
364
|
+
common_dirs = [name for name in left_dirs if name in right_dirs]
|
|
365
|
+
|
|
366
|
+
left_files_missing = [name for name in right_files if name not in left_files]
|
|
367
|
+
right_files_missing = [name for name in left_files if name not in right_files]
|
|
368
|
+
left_dirs_missing = [name for name in right_dirs if name not in left_dirs]
|
|
369
|
+
right_dirs_missing = [name for name in left_dirs if name not in right_dirs]
|
|
370
|
+
for name in left_files_missing:
|
|
371
|
+
result += generate_entry(
|
|
372
|
+
next_entry_id(),
|
|
373
|
+
directory_id,
|
|
374
|
+
depth + 1,
|
|
375
|
+
False,
|
|
376
|
+
False,
|
|
377
|
+
common_path / name,
|
|
378
|
+
name,
|
|
379
|
+
"file missing in A",
|
|
380
|
+
cmp_result="different",
|
|
381
|
+
)
|
|
382
|
+
for name in right_files_missing:
|
|
383
|
+
result += generate_entry(
|
|
384
|
+
next_entry_id(),
|
|
385
|
+
directory_id,
|
|
386
|
+
depth + 1,
|
|
387
|
+
False,
|
|
388
|
+
False,
|
|
389
|
+
common_path / name,
|
|
390
|
+
name,
|
|
391
|
+
"file missing in B",
|
|
392
|
+
cmp_result="different",
|
|
393
|
+
)
|
|
394
|
+
for name in left_dirs_missing:
|
|
395
|
+
result += generate_entry(
|
|
396
|
+
next_entry_id(),
|
|
397
|
+
directory_id,
|
|
398
|
+
depth + 1,
|
|
399
|
+
False,
|
|
400
|
+
False,
|
|
401
|
+
common_path / name,
|
|
402
|
+
name + "/",
|
|
403
|
+
"dir missing in A",
|
|
404
|
+
cmp_result="different",
|
|
405
|
+
)
|
|
406
|
+
for name in right_dirs_missing:
|
|
407
|
+
result += generate_entry(
|
|
408
|
+
next_entry_id(),
|
|
409
|
+
directory_id,
|
|
410
|
+
depth + 1,
|
|
411
|
+
False,
|
|
412
|
+
False,
|
|
413
|
+
common_path / name,
|
|
414
|
+
name + "/",
|
|
415
|
+
"dir missing in B",
|
|
416
|
+
cmp_result="different",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
for name in common_files:
|
|
420
|
+
result += generate_entry(
|
|
421
|
+
next_entry_id(),
|
|
422
|
+
directory_id,
|
|
423
|
+
depth + 1,
|
|
424
|
+
False,
|
|
425
|
+
True,
|
|
426
|
+
common_path / name,
|
|
427
|
+
name,
|
|
428
|
+
"",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
for name in common_dirs:
|
|
432
|
+
result += generate_tree(a / name, b / name, name, directory_id, depth + 1)
|
|
433
|
+
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
result = """<!DOCTYPE html>
|
|
437
|
+
<html>
|
|
438
|
+
<head>
|
|
439
|
+
<style>
|
|
440
|
+
tr {
|
|
441
|
+
--depth: attr(data-depth number);
|
|
442
|
+
}
|
|
443
|
+
.main {
|
|
444
|
+
padding-left: calc(1.0rem * var(--depth));
|
|
445
|
+
}
|
|
446
|
+
</style>
|
|
447
|
+
</head>
|
|
448
|
+
<body>
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
result += "<p>"
|
|
452
|
+
result += "comparing<br>"
|
|
453
|
+
result += f"Reference: {Config.path_a}<br>"
|
|
454
|
+
result += f"Monitored: {Config.path_b}"
|
|
455
|
+
result += "</p>"
|
|
456
|
+
|
|
457
|
+
if Config.log_file is not None:
|
|
458
|
+
result += "<p>"
|
|
459
|
+
result += (
|
|
460
|
+
f'<a href="/logfile" target="_blank">View log file: {Config.log_file}</a>'
|
|
461
|
+
)
|
|
462
|
+
result += "</p>"
|
|
463
|
+
|
|
464
|
+
result += "<p>"
|
|
465
|
+
result += '<button onclick="toggleAll(true)">Expand All</button>'
|
|
466
|
+
result += '<button onclick="toggleAll(false)">Collapse All</button>'
|
|
467
|
+
result += "</p>"
|
|
468
|
+
|
|
469
|
+
result += "<table>"
|
|
470
|
+
result += "<thead>"
|
|
471
|
+
result += "<tr><td></td><td></td><td>Name</td><td>Message</td><td>Actions</td></tr>"
|
|
472
|
+
result += "</thead>"
|
|
473
|
+
result += "<tbody>"
|
|
474
|
+
result += generate_tree(Config.path_a, Config.path_b, "", None, 0)
|
|
475
|
+
result += "</tbody>"
|
|
476
|
+
result += "</table>"
|
|
477
|
+
|
|
478
|
+
result += """
|
|
479
|
+
<script>
|
|
480
|
+
document.addEventListener("click", e => {
|
|
481
|
+
if (!e.target.classList.contains("toggle")) return;
|
|
482
|
+
|
|
483
|
+
const row = e.target.closest("tr");
|
|
484
|
+
const id = row.dataset.entryId;
|
|
485
|
+
const expanded = e.target.textContent === "▼";
|
|
486
|
+
|
|
487
|
+
e.target.textContent = expanded ? "▶" : "▼";
|
|
488
|
+
|
|
489
|
+
toggleChildren(id, !expanded);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
function toggleChildren(parentId, show) {
|
|
493
|
+
document.querySelectorAll(`tr[data-parent-id="${parentId}"]`)
|
|
494
|
+
.forEach(child => {
|
|
495
|
+
child.hidden = !show;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function toggleAll(show) {
|
|
500
|
+
document.querySelectorAll("tr")
|
|
501
|
+
.forEach(row => {
|
|
502
|
+
const isRoot = !row.dataset.parentId;
|
|
503
|
+
row.hidden = !isRoot && !show;
|
|
504
|
+
const toggle = row.querySelector(".toggle");
|
|
505
|
+
if (toggle) {
|
|
506
|
+
toggle.textContent = show ? "▼" : "▶";
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function updateRef(path) {
|
|
512
|
+
fetch(`/update_ref/${path}`)
|
|
513
|
+
.then(response => {
|
|
514
|
+
if (response.ok) {
|
|
515
|
+
alert(`Reference updated for ${path}`);
|
|
516
|
+
location.reload();
|
|
517
|
+
} else {
|
|
518
|
+
alert(`Failed to update reference for ${path}: ${response.statusText}`);
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
.catch(error => {
|
|
522
|
+
alert(`Error updating reference for ${path}: ${error}`);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
</script>
|
|
526
|
+
</body>
|
|
527
|
+
</html>
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
return result
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@app.route("/logfile")
|
|
534
|
+
def logfile():
|
|
535
|
+
logger.debug("Serving log file")
|
|
536
|
+
|
|
537
|
+
if Config.log_file is None:
|
|
538
|
+
return "No log file configured", 404
|
|
539
|
+
|
|
540
|
+
return send_from_directory(Config.log_file.parent, Config.log_file.name)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@app.route("/compare/<path:path>")
|
|
544
|
+
def compare(path: str):
|
|
545
|
+
logger.debug(f"Generating comparison page for path: {path}")
|
|
546
|
+
|
|
547
|
+
if not isinstance(path, str):
|
|
548
|
+
raise TypeError("Path must be a string")
|
|
549
|
+
|
|
550
|
+
return f"""<!DOCTYPE html>
|
|
551
|
+
<html>
|
|
552
|
+
<head>
|
|
553
|
+
<style>
|
|
554
|
+
html,body {{height:100%;margin:0;}}
|
|
555
|
+
</style>
|
|
556
|
+
</head>
|
|
557
|
+
<body style="display:flex;flex-flow:row;">
|
|
558
|
+
<div style="display:flex;flex:1;flex-flow:column;margin:5px;">
|
|
559
|
+
<a href="/file/a/{path}" target="_blank">{Config.path_a / path}</a>
|
|
560
|
+
<iframe id="a" src="/file/a/{path}" title="a" frameborder="0" align="left" style="flex:1;"></iframe>
|
|
561
|
+
</div>
|
|
562
|
+
<div style="display:flex;flex:0 0 50px;flex-flow:column;">
|
|
563
|
+
<a href="/image_diff/{path}" target="_blank">diff</a>
|
|
564
|
+
<img src="/image_diff/{path}" width="50" height="0" style="flex:1;">
|
|
565
|
+
</div>
|
|
566
|
+
<div style="display:flex;flex:1;flex-flow:column;margin:5px;">
|
|
567
|
+
<a href="/file/b/{path}" target="_blank">{Config.path_b / path}</a>
|
|
568
|
+
<iframe id="b" src="/file/b/{path}" title="b" frameborder="0" align="right" style="flex:1;"></iframe>
|
|
569
|
+
</div>
|
|
570
|
+
<script>
|
|
571
|
+
var iframe_a = document.getElementById('a');
|
|
572
|
+
var iframe_b = document.getElementById('b');
|
|
573
|
+
iframe_a.contentWindow.addEventListener('scroll', function(event) {{
|
|
574
|
+
iframe_b.contentWindow.scrollTo(iframe_a.contentWindow.scrollX, iframe_a.contentWindow.scrollY);
|
|
575
|
+
}});
|
|
576
|
+
iframe_b.contentWindow.addEventListener('scroll', function(event) {{
|
|
577
|
+
iframe_a.contentWindow.scrollTo(iframe_b.contentWindow.scrollX, iframe_b.contentWindow.scrollY);
|
|
578
|
+
}});
|
|
579
|
+
</script>
|
|
580
|
+
</body>
|
|
581
|
+
</html>
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@app.route("/image_diff/<path:path>")
|
|
586
|
+
def image_diff(path: str):
|
|
587
|
+
logger.debug(f"Generating image diff for path: {path}")
|
|
588
|
+
|
|
589
|
+
if not isinstance(path, str):
|
|
590
|
+
raise TypeError("Path must be a string")
|
|
591
|
+
|
|
592
|
+
if Config.driver is None:
|
|
593
|
+
return "Image diff not available without browser driver", 404
|
|
594
|
+
|
|
595
|
+
diff, _ = html_render_diff(
|
|
596
|
+
Config.path_a / path,
|
|
597
|
+
Config.path_b / path,
|
|
598
|
+
Config.browser,
|
|
599
|
+
)
|
|
600
|
+
tmp = io.BytesIO()
|
|
601
|
+
diff.save(tmp, "JPEG", quality=70)
|
|
602
|
+
tmp.seek(0)
|
|
603
|
+
return send_file(tmp, mimetype="image/jpeg")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.route("/file/<variant>/<path:path>")
|
|
607
|
+
def file(variant: str, path: str):
|
|
608
|
+
logger.debug(f"Serving file for variant: {variant}, path: {path}")
|
|
609
|
+
|
|
610
|
+
if not isinstance(variant, str) or not isinstance(path, str):
|
|
611
|
+
raise TypeError("Variant and path must be strings")
|
|
612
|
+
if variant not in ["a", "b"]:
|
|
613
|
+
raise ValueError("Variant must be 'a' or 'b'")
|
|
614
|
+
|
|
615
|
+
variant_root = Config.path_a if variant == "a" else Config.path_b
|
|
616
|
+
return send_from_directory(variant_root, path)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def verbosity_to_level(verbosity: int) -> int:
|
|
620
|
+
if verbosity >= 3:
|
|
621
|
+
return logging.DEBUG
|
|
622
|
+
elif verbosity == 2:
|
|
623
|
+
return logging.INFO
|
|
624
|
+
elif verbosity == 1:
|
|
625
|
+
return logging.WARNING
|
|
626
|
+
else:
|
|
627
|
+
return logging.ERROR
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def setup_logging(
|
|
631
|
+
verbosity: int, log_file: Path = None, log_file_verbosity: int = None
|
|
632
|
+
) -> None:
|
|
633
|
+
level = verbosity_to_level(verbosity)
|
|
634
|
+
|
|
635
|
+
root_logger = logging.getLogger()
|
|
636
|
+
root_logger.setLevel(level)
|
|
637
|
+
root_logger.handlers.clear()
|
|
638
|
+
|
|
639
|
+
formatter = logging.Formatter(
|
|
640
|
+
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
641
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
645
|
+
console_handler.setLevel(level)
|
|
646
|
+
console_handler.setFormatter(formatter)
|
|
647
|
+
root_logger.addHandler(console_handler)
|
|
648
|
+
|
|
649
|
+
if log_file is not None:
|
|
650
|
+
file_level = (
|
|
651
|
+
verbosity_to_level(log_file_verbosity)
|
|
652
|
+
if log_file_verbosity is not None
|
|
653
|
+
else level
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
|
657
|
+
file_handler.setLevel(file_level)
|
|
658
|
+
file_handler.setFormatter(formatter)
|
|
659
|
+
root_logger.addHandler(file_handler)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@app.route("/update_ref/<path:path>")
|
|
663
|
+
def update_ref(path: str):
|
|
664
|
+
logger.debug(f"Updating reference for path: {path}")
|
|
665
|
+
|
|
666
|
+
if not isinstance(path, str):
|
|
667
|
+
raise TypeError("Path must be a string")
|
|
668
|
+
|
|
669
|
+
src = Config.path_b / path
|
|
670
|
+
dst = Config.path_a / path
|
|
671
|
+
|
|
672
|
+
if not src.exists():
|
|
673
|
+
return f"Source file does not exist: {src}", 404
|
|
674
|
+
|
|
675
|
+
if src.is_file():
|
|
676
|
+
shutil.copy2(src, dst)
|
|
677
|
+
else:
|
|
678
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
679
|
+
|
|
680
|
+
return "Reference updated", 200
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def main():
|
|
684
|
+
parser = argparse.ArgumentParser()
|
|
685
|
+
parser.add_argument("ref", type=Path, help="Path to the reference directory")
|
|
686
|
+
parser.add_argument("mon", type=Path, help="Path to the monitored directory")
|
|
687
|
+
parser.add_argument("--driver", choices=["chrome", "firefox", "phantomjs"])
|
|
688
|
+
parser.add_argument("--max-workers", type=int, default=1)
|
|
689
|
+
parser.add_argument("--compare", action="store_true")
|
|
690
|
+
parser.add_argument("--port", type=int, default=5000)
|
|
691
|
+
parser.add_argument(
|
|
692
|
+
"-v",
|
|
693
|
+
"--verbose",
|
|
694
|
+
action="count",
|
|
695
|
+
default=0,
|
|
696
|
+
help="Increase verbosity (-v, -vv, -vvv)",
|
|
697
|
+
)
|
|
698
|
+
parser.add_argument(
|
|
699
|
+
"--log-file",
|
|
700
|
+
type=Path,
|
|
701
|
+
help="Path to log file",
|
|
702
|
+
)
|
|
703
|
+
parser.add_argument(
|
|
704
|
+
"--log-file-verbosity", type=int, help="Log file verbosity level"
|
|
705
|
+
)
|
|
706
|
+
args = parser.parse_args()
|
|
707
|
+
|
|
708
|
+
setup_logging(args.verbose, args.log_file, args.log_file_verbosity)
|
|
709
|
+
|
|
710
|
+
Config.path_a = args.ref
|
|
711
|
+
Config.path_b = args.mon
|
|
712
|
+
Config.driver = args.driver
|
|
713
|
+
Config.browser = (
|
|
714
|
+
get_browser(driver=args.driver) if args.driver is not None else None
|
|
715
|
+
)
|
|
716
|
+
Config.log_file = args.log_file
|
|
717
|
+
|
|
718
|
+
if args.compare:
|
|
719
|
+
Config.comparator = Comparator(max_workers=args.max_workers)
|
|
720
|
+
|
|
721
|
+
Config.observer = Observer()
|
|
722
|
+
Config.observer.start()
|
|
723
|
+
|
|
724
|
+
app.run(host="0.0.0.0", port=args.port)
|
|
725
|
+
|
|
726
|
+
if args.compare:
|
|
727
|
+
Config.observer.stop()
|
|
728
|
+
Config.observer.join()
|
|
729
|
+
|
|
730
|
+
return 0
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
if __name__ == "__main__":
|
|
734
|
+
sys.exit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlcmp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: Compare HTML files by rendered output
|
|
5
5
|
Author: Andreas Stefl
|
|
6
6
|
Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
|
|
@@ -53,3 +53,15 @@ docker run -ti \
|
|
|
53
53
|
```bash
|
|
54
54
|
docker build --tag odr_core_test test/docker
|
|
55
55
|
```
|
|
56
|
+
|
|
57
|
+
## Run locally
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
PYTHONPATH=$(pwd)/src:$PYTHONPATH python ./src/htmlcmp/compare_output_server.py \
|
|
61
|
+
/path/to/REFERENCE \
|
|
62
|
+
/path/to/MONITORED \
|
|
63
|
+
--compare \
|
|
64
|
+
--driver firefox \
|
|
65
|
+
--port 8000 \
|
|
66
|
+
-vv
|
|
67
|
+
```
|
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
|
|
4
|
-
import io
|
|
5
|
-
import sys
|
|
6
|
-
import argparse
|
|
7
|
-
import logging
|
|
8
|
-
import threading
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
-
|
|
12
|
-
from flask import Flask, send_from_directory, send_file
|
|
13
|
-
import watchdog.observers
|
|
14
|
-
import watchdog.events
|
|
15
|
-
|
|
16
|
-
from htmlcmp.compare_output import comparable_file, compare_files
|
|
17
|
-
from htmlcmp.html_render_diff import get_browser, html_render_diff
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class Config:
|
|
24
|
-
path_a: Path = None
|
|
25
|
-
path_b: Path = None
|
|
26
|
-
driver: str = None
|
|
27
|
-
observer = None
|
|
28
|
-
comparator = None
|
|
29
|
-
browser = None
|
|
30
|
-
thread_local = threading.local()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class Observer:
|
|
34
|
-
def __init__(self):
|
|
35
|
-
class Handler(watchdog.events.FileSystemEventHandler):
|
|
36
|
-
def __init__(self, path):
|
|
37
|
-
self._path = path
|
|
38
|
-
|
|
39
|
-
def dispatch(self, event):
|
|
40
|
-
event_type = event.event_type
|
|
41
|
-
src_path = Path(event.src_path)
|
|
42
|
-
|
|
43
|
-
logger.debug(f"Watchdog event: {event_type} {src_path}")
|
|
44
|
-
|
|
45
|
-
if event_type not in ["moved", "deleted", "created", "modified"]:
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
if src_path.is_file():
|
|
49
|
-
logger.debug(
|
|
50
|
-
f"Submit watchdog file change: {event_type} {src_path}"
|
|
51
|
-
)
|
|
52
|
-
Config.comparator.submit(src_path.relative_to(self._path))
|
|
53
|
-
|
|
54
|
-
logger.info("Create watchdog for paths:")
|
|
55
|
-
logger.info(f" A: {Config.path_a}")
|
|
56
|
-
logger.info(f" B: {Config.path_b}")
|
|
57
|
-
|
|
58
|
-
self._observer = watchdog.observers.Observer()
|
|
59
|
-
self._observer.schedule(Handler(Config.path_a), Config.path_a, recursive=True)
|
|
60
|
-
self._observer.schedule(Handler(Config.path_b), Config.path_b, recursive=True)
|
|
61
|
-
|
|
62
|
-
def start(self):
|
|
63
|
-
logger.info("Starting watchdog observer")
|
|
64
|
-
self._observer.start()
|
|
65
|
-
|
|
66
|
-
def init_compare(a: Path, b: Path):
|
|
67
|
-
logger.debug(f"Initial compare: {a} vs {b}")
|
|
68
|
-
|
|
69
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
70
|
-
raise TypeError("Paths must be of type Path")
|
|
71
|
-
if not a.is_dir() or not b.is_dir():
|
|
72
|
-
raise ValueError("Both paths must be directories")
|
|
73
|
-
|
|
74
|
-
common_path = a.relative_to(Config.path_a)
|
|
75
|
-
|
|
76
|
-
left = sorted(p.name for p in a.iterdir())
|
|
77
|
-
right = sorted(p.name for p in b.iterdir())
|
|
78
|
-
|
|
79
|
-
common = [name for name in left if name in right]
|
|
80
|
-
|
|
81
|
-
for name in common:
|
|
82
|
-
if (a / name).is_file() and comparable_file(a / name):
|
|
83
|
-
logger.debug(
|
|
84
|
-
f"Submit initial file comparison: {common_path / name}"
|
|
85
|
-
)
|
|
86
|
-
Config.comparator.submit(common_path / name)
|
|
87
|
-
elif (a / name).is_dir():
|
|
88
|
-
init_compare(a / name, b / name)
|
|
89
|
-
|
|
90
|
-
logger.info("Kick off initial comparison of all files")
|
|
91
|
-
init_compare(Config.path_a, Config.path_b)
|
|
92
|
-
logger.info("Initial comparison submitted")
|
|
93
|
-
|
|
94
|
-
def stop(self):
|
|
95
|
-
logger.info("Stopping watchdog observer")
|
|
96
|
-
self._observer.stop()
|
|
97
|
-
|
|
98
|
-
def join(self):
|
|
99
|
-
logger.info("Joining watchdog observer")
|
|
100
|
-
self._observer.join()
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class Comparator:
|
|
104
|
-
def __init__(self, max_workers: int):
|
|
105
|
-
def initializer():
|
|
106
|
-
browser = getattr(Config.thread_local, "browser", None)
|
|
107
|
-
if browser is None:
|
|
108
|
-
browser = get_browser(driver=Config.driver)
|
|
109
|
-
Config.thread_local.browser = browser
|
|
110
|
-
|
|
111
|
-
logger.info(f"Creating comparator with {max_workers} workers")
|
|
112
|
-
|
|
113
|
-
self._executor = ThreadPoolExecutor(
|
|
114
|
-
max_workers=max_workers, initializer=initializer
|
|
115
|
-
)
|
|
116
|
-
self._result = {}
|
|
117
|
-
self._future = {}
|
|
118
|
-
|
|
119
|
-
def submit(self, path: Path):
|
|
120
|
-
logger.debug(f"Submitting comparison for path: {path}")
|
|
121
|
-
|
|
122
|
-
if not isinstance(path, Path):
|
|
123
|
-
raise TypeError("Path must be of type Path")
|
|
124
|
-
|
|
125
|
-
if path in self._future:
|
|
126
|
-
try:
|
|
127
|
-
self._future[path].cancel()
|
|
128
|
-
self._future[path].result()
|
|
129
|
-
self._future.pop(path)
|
|
130
|
-
except Exception:
|
|
131
|
-
pass
|
|
132
|
-
|
|
133
|
-
self._result[path] = "pending"
|
|
134
|
-
self._future[path] = self._executor.submit(self.compare, path)
|
|
135
|
-
|
|
136
|
-
def compare(self, path: Path):
|
|
137
|
-
logger.debug(f"Comparing files for path: {path}")
|
|
138
|
-
|
|
139
|
-
if not isinstance(path, Path):
|
|
140
|
-
raise TypeError("Path must be of type Path")
|
|
141
|
-
if path not in self._future:
|
|
142
|
-
raise RuntimeError("Path not submitted for comparison")
|
|
143
|
-
|
|
144
|
-
browser = getattr(Config.thread_local, "browser", None)
|
|
145
|
-
result = compare_files(
|
|
146
|
-
Config.path_a / path,
|
|
147
|
-
Config.path_b / path,
|
|
148
|
-
browser=browser,
|
|
149
|
-
)
|
|
150
|
-
self._result[path] = "same" if result else "different"
|
|
151
|
-
self._future.pop(path)
|
|
152
|
-
|
|
153
|
-
def result(self, path: Path):
|
|
154
|
-
logger.debug(f"Getting comparison result for path: {path}")
|
|
155
|
-
|
|
156
|
-
if not isinstance(path, Path):
|
|
157
|
-
raise TypeError("Path must be of type Path")
|
|
158
|
-
|
|
159
|
-
if path in self._result:
|
|
160
|
-
return self._result[path]
|
|
161
|
-
return "unknown"
|
|
162
|
-
|
|
163
|
-
def result_symbol(self, path: Path):
|
|
164
|
-
logger.debug(f"Getting comparison result symbol for path: {path}")
|
|
165
|
-
|
|
166
|
-
if not isinstance(path, Path):
|
|
167
|
-
raise TypeError("Path must be of type Path")
|
|
168
|
-
|
|
169
|
-
result = self.result(path)
|
|
170
|
-
if result == "pending":
|
|
171
|
-
return "🔄"
|
|
172
|
-
if result == "same":
|
|
173
|
-
return "✔"
|
|
174
|
-
if result == "different":
|
|
175
|
-
return "❌"
|
|
176
|
-
return "⛔"
|
|
177
|
-
|
|
178
|
-
def result_css(self, path: Path):
|
|
179
|
-
logger.debug(f"Getting comparison result CSS for path: {path}")
|
|
180
|
-
|
|
181
|
-
if not isinstance(path, Path):
|
|
182
|
-
raise TypeError("Path must be of type Path")
|
|
183
|
-
|
|
184
|
-
result = self.result(path)
|
|
185
|
-
if result == "pending":
|
|
186
|
-
return "color:blue;"
|
|
187
|
-
if result == "same":
|
|
188
|
-
return "color:green;"
|
|
189
|
-
if result == "different":
|
|
190
|
-
return "color:orange;"
|
|
191
|
-
return "color:red;"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
app = Flask("compare")
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
@app.route("/")
|
|
198
|
-
def root():
|
|
199
|
-
logger.debug("Generating root directory listing")
|
|
200
|
-
|
|
201
|
-
def print_tree(a: Path, b: Path):
|
|
202
|
-
if not isinstance(a, Path) or not isinstance(b, Path):
|
|
203
|
-
raise TypeError("Paths must be of type Path")
|
|
204
|
-
if not a.is_dir() or not b.is_dir():
|
|
205
|
-
raise ValueError("Both paths must be directories")
|
|
206
|
-
|
|
207
|
-
common_path = a.relative_to(Config.path_a)
|
|
208
|
-
|
|
209
|
-
left = sorted(p.name for p in a.iterdir())
|
|
210
|
-
right = sorted(p.name for p in b.iterdir())
|
|
211
|
-
|
|
212
|
-
left_files = sorted(
|
|
213
|
-
[
|
|
214
|
-
name
|
|
215
|
-
for name in left
|
|
216
|
-
if (a / name).is_file() and comparable_file(a / name)
|
|
217
|
-
]
|
|
218
|
-
)
|
|
219
|
-
right_files = sorted(
|
|
220
|
-
[
|
|
221
|
-
name
|
|
222
|
-
for name in right
|
|
223
|
-
if (b / name).is_file() and comparable_file(b / name)
|
|
224
|
-
]
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
left_dirs = sorted([name for name in left if (a / name).is_dir()])
|
|
228
|
-
right_dirs = sorted([name for name in right if (b / name).is_dir()])
|
|
229
|
-
|
|
230
|
-
common_files = [name for name in left_files if name in right_files]
|
|
231
|
-
common_dirs = [name for name in left_dirs if name in right_dirs]
|
|
232
|
-
|
|
233
|
-
result = "<ul>"
|
|
234
|
-
|
|
235
|
-
left_files_missing = " ".join(
|
|
236
|
-
[name for name in right_files if name not in left_files]
|
|
237
|
-
)
|
|
238
|
-
right_files_missing = " ".join(
|
|
239
|
-
[name for name in left_files if name not in right_files]
|
|
240
|
-
)
|
|
241
|
-
left_dirs_missing = " ".join(
|
|
242
|
-
[name for name in right_dirs if name not in left_dirs]
|
|
243
|
-
)
|
|
244
|
-
right_dirs_missing = " ".join(
|
|
245
|
-
[name for name in left_dirs if name not in right_dirs]
|
|
246
|
-
)
|
|
247
|
-
if left_files_missing:
|
|
248
|
-
result += f"<li><b>A files missing: {left_files_missing}</b></li>"
|
|
249
|
-
if right_files_missing:
|
|
250
|
-
result += f"<li><b>B files missing: {right_files_missing}</b></li>"
|
|
251
|
-
if left_dirs_missing:
|
|
252
|
-
result += f"<li><b>A dirs missing: {left_dirs_missing}</b></li>"
|
|
253
|
-
if right_dirs_missing:
|
|
254
|
-
result += f"<li><b>B dirs missing: {right_dirs_missing}</b></li>"
|
|
255
|
-
|
|
256
|
-
for name in common_files:
|
|
257
|
-
if Config.comparator is None:
|
|
258
|
-
result += f'<li><a href="/compare/{common_path / name}">{name}</a></li>'
|
|
259
|
-
else:
|
|
260
|
-
symbol = Config.comparator.result_symbol(common_path / name)
|
|
261
|
-
css = Config.comparator.result_css(common_path / name)
|
|
262
|
-
result += f'<li style="{css}"><a style="{css}" href="/compare/{common_path / name}">{name}</a> {symbol}</li>'
|
|
263
|
-
|
|
264
|
-
for name in common_dirs:
|
|
265
|
-
result += f"<li>{name}"
|
|
266
|
-
result += print_tree(a / name, b / name)
|
|
267
|
-
result += "</li>"
|
|
268
|
-
|
|
269
|
-
result += "</ul>"
|
|
270
|
-
return result
|
|
271
|
-
|
|
272
|
-
result = ""
|
|
273
|
-
result += f"<p>compare A {Config.path_a} vs B {Config.path_b}</p>"
|
|
274
|
-
result += print_tree(Config.path_a, Config.path_b)
|
|
275
|
-
return result
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
@app.route("/compare/<path:path>")
|
|
279
|
-
def compare(path: str):
|
|
280
|
-
logger.debug(f"Generating comparison page for path: {path}")
|
|
281
|
-
|
|
282
|
-
if not isinstance(path, str):
|
|
283
|
-
raise TypeError("Path must be a string")
|
|
284
|
-
|
|
285
|
-
return f"""<!DOCTYPE html>
|
|
286
|
-
<html>
|
|
287
|
-
<head>
|
|
288
|
-
<style>
|
|
289
|
-
html,body {{height:100%;margin:0;}}
|
|
290
|
-
</style>
|
|
291
|
-
</head>
|
|
292
|
-
<body style="display:flex;flex-flow:row;">
|
|
293
|
-
<div style="display:flex;flex:1;flex-flow:column;margin:5px;">
|
|
294
|
-
<a href="/file/a/{path}">{Config.path_a / path}</a>
|
|
295
|
-
<iframe id="a" src="/file/a/{path}" title="a" frameborder="0" align="left" style="flex:1;"></iframe>
|
|
296
|
-
</div>
|
|
297
|
-
<div style="display:flex;flex:0 0 50px;flex-flow:column;">
|
|
298
|
-
<a href="/image_diff/{path}">diff</a>
|
|
299
|
-
<img src="/image_diff/{path}" width="50" height="0" style="flex:1;">
|
|
300
|
-
</div>
|
|
301
|
-
<div style="display:flex;flex:1;flex-flow:column;margin:5px;">
|
|
302
|
-
<a href="/file/b/{path}">{Config.path_b / path}</a>
|
|
303
|
-
<iframe id="b" src="/file/b/{path}" title="b" frameborder="0" align="right" style="flex:1;"></iframe>
|
|
304
|
-
</div>
|
|
305
|
-
<script>
|
|
306
|
-
var iframe_a = document.getElementById('a');
|
|
307
|
-
var iframe_b = document.getElementById('b');
|
|
308
|
-
iframe_a.contentWindow.addEventListener('scroll', function(event) {{
|
|
309
|
-
iframe_b.contentWindow.scrollTo(iframe_a.contentWindow.scrollX, iframe_a.contentWindow.scrollY);
|
|
310
|
-
}});
|
|
311
|
-
iframe_b.contentWindow.addEventListener('scroll', function(event) {{
|
|
312
|
-
iframe_a.contentWindow.scrollTo(iframe_b.contentWindow.scrollX, iframe_b.contentWindow.scrollY);
|
|
313
|
-
}});
|
|
314
|
-
</script>
|
|
315
|
-
</body>
|
|
316
|
-
</html>
|
|
317
|
-
"""
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
@app.route("/image_diff/<path:path>")
|
|
321
|
-
def image_diff(path: str):
|
|
322
|
-
logger.debug(f"Generating image diff for path: {path}")
|
|
323
|
-
|
|
324
|
-
if not isinstance(path, str):
|
|
325
|
-
raise TypeError("Path must be a string")
|
|
326
|
-
|
|
327
|
-
diff, _ = html_render_diff(
|
|
328
|
-
Config.path_a / path,
|
|
329
|
-
Config.path_b / path,
|
|
330
|
-
Config.browser,
|
|
331
|
-
)
|
|
332
|
-
tmp = io.BytesIO()
|
|
333
|
-
diff.save(tmp, "JPEG", quality=70)
|
|
334
|
-
tmp.seek(0)
|
|
335
|
-
return send_file(tmp, mimetype="image/jpeg")
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
@app.route("/file/<variant>/<path:path>")
|
|
339
|
-
def file(variant: str, path: str):
|
|
340
|
-
logger.debug(f"Serving file for variant: {variant}, path: {path}")
|
|
341
|
-
|
|
342
|
-
if not isinstance(variant, str) or not isinstance(path, str):
|
|
343
|
-
raise TypeError("Variant and path must be strings")
|
|
344
|
-
if variant not in ["a", "b"]:
|
|
345
|
-
raise ValueError("Variant must be 'a' or 'b'")
|
|
346
|
-
|
|
347
|
-
variant_root = Config.path_a if variant == "a" else Config.path_b
|
|
348
|
-
return send_from_directory(variant_root, path)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def setup_logging(verbosity: int):
|
|
352
|
-
if verbosity >= 3:
|
|
353
|
-
level = logging.DEBUG
|
|
354
|
-
elif verbosity == 2:
|
|
355
|
-
level = logging.INFO
|
|
356
|
-
elif verbosity == 1:
|
|
357
|
-
level = logging.WARNING
|
|
358
|
-
else:
|
|
359
|
-
level = logging.ERROR
|
|
360
|
-
|
|
361
|
-
formatter = logging.Formatter(
|
|
362
|
-
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
363
|
-
datefmt="%Y-%m-%d %H:%M:%S",
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
console_handler = logging.StreamHandler(sys.stderr)
|
|
367
|
-
console_handler.setFormatter(formatter)
|
|
368
|
-
|
|
369
|
-
root_logger = logging.getLogger()
|
|
370
|
-
root_logger.setLevel(level)
|
|
371
|
-
root_logger.handlers.clear()
|
|
372
|
-
root_logger.addHandler(console_handler)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def main():
|
|
376
|
-
parser = argparse.ArgumentParser()
|
|
377
|
-
parser.add_argument("a", type=Path, help="Path to the first directory")
|
|
378
|
-
parser.add_argument("b", type=Path, help="Path to the second directory")
|
|
379
|
-
parser.add_argument(
|
|
380
|
-
"--driver", choices=["chrome", "firefox", "phantomjs"], default="firefox"
|
|
381
|
-
)
|
|
382
|
-
parser.add_argument("--max-workers", type=int, default=1)
|
|
383
|
-
parser.add_argument("--compare", action="store_true")
|
|
384
|
-
parser.add_argument("--port", type=int, default=5000)
|
|
385
|
-
parser.add_argument(
|
|
386
|
-
"-v",
|
|
387
|
-
"--verbose",
|
|
388
|
-
action="count",
|
|
389
|
-
default=0,
|
|
390
|
-
help="Increase verbosity (-v, -vv, -vvv)",
|
|
391
|
-
)
|
|
392
|
-
args = parser.parse_args()
|
|
393
|
-
|
|
394
|
-
setup_logging(args.verbose)
|
|
395
|
-
|
|
396
|
-
Config.path_a = args.a
|
|
397
|
-
Config.path_b = args.b
|
|
398
|
-
Config.driver = args.driver
|
|
399
|
-
Config.browser = get_browser(driver=args.driver)
|
|
400
|
-
|
|
401
|
-
if args.compare:
|
|
402
|
-
Config.comparator = Comparator(max_workers=args.max_workers)
|
|
403
|
-
|
|
404
|
-
Config.observer = Observer()
|
|
405
|
-
Config.observer.start()
|
|
406
|
-
|
|
407
|
-
app.run(host="0.0.0.0", port=args.port)
|
|
408
|
-
|
|
409
|
-
if args.compare:
|
|
410
|
-
Config.observer.stop()
|
|
411
|
-
Config.observer.join()
|
|
412
|
-
|
|
413
|
-
return 0
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if __name__ == "__main__":
|
|
417
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|