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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.0.18
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
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlcmp"
3
- version = "1.0.18"
3
+ version = "1.1.1"
4
4
  description = "Compare HTML files by rendered output"
5
5
  classifiers = []
6
6
  authors = [
@@ -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.0.18
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