htmlcmp 1.1.0__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.1.0
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.1.0"
3
+ version = "1.1.1"
4
4
  description = "Compare HTML files by rendered output"
5
5
  classifiers = []
6
6
  authors = [
@@ -3,6 +3,7 @@
3
3
 
4
4
  import io
5
5
  import sys
6
+ import shutil
6
7
  import argparse
7
8
  import logging
8
9
  import threading
@@ -29,9 +30,10 @@ class Config:
29
30
  comparator = None
30
31
  browser = None
31
32
  thread_local = threading.local()
33
+ log_file: Path = None
32
34
 
33
35
 
34
- def result_symbol(result: str):
36
+ def result_symbol(result: str) -> str | None:
35
37
  if not isinstance(result, str):
36
38
  raise TypeError("Result must be of type str")
37
39
 
@@ -41,10 +43,10 @@ def result_symbol(result: str):
41
43
  return "✔"
42
44
  if result == "different":
43
45
  return "❌"
44
- return "⛔"
46
+ return None
45
47
 
46
48
 
47
- def result_css(result: str):
49
+ def result_css(result: str) -> str | None:
48
50
  if not isinstance(result, str):
49
51
  raise TypeError("Result must be of type str")
50
52
 
@@ -53,8 +55,8 @@ def result_css(result: str):
53
55
  if result == "same":
54
56
  return "color:green;"
55
57
  if result == "different":
56
- return "color:orange;"
57
- return "color:red;"
58
+ return "color:red;"
59
+ return None
58
60
 
59
61
 
60
62
  class Observer:
@@ -86,7 +88,7 @@ class Observer:
86
88
  self._observer.schedule(Handler(Config.path_a), Config.path_a, recursive=True)
87
89
  self._observer.schedule(Handler(Config.path_b), Config.path_b, recursive=True)
88
90
 
89
- def start(self):
91
+ def start(self) -> None:
90
92
  logger.info("Starting watchdog observer")
91
93
  self._observer.start()
92
94
 
@@ -118,11 +120,11 @@ class Observer:
118
120
  init_compare(Config.path_a, Config.path_b)
119
121
  logger.info("Initial comparison submitted")
120
122
 
121
- def stop(self):
123
+ def stop(self) -> None:
122
124
  logger.info("Stopping watchdog observer")
123
125
  self._observer.stop()
124
126
 
125
- def join(self):
127
+ def join(self) -> None:
126
128
  logger.info("Joining watchdog observer")
127
129
  self._observer.join()
128
130
 
@@ -130,10 +132,11 @@ class Observer:
130
132
  class Comparator:
131
133
  def __init__(self, max_workers: int):
132
134
  def initializer():
133
- browser = getattr(Config.thread_local, "browser", None)
134
- if browser is None:
135
- browser = get_browser(driver=Config.driver)
136
- Config.thread_local.browser = browser
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
137
140
 
138
141
  logger.info(f"Creating comparator with {max_workers} workers")
139
142
 
@@ -143,12 +146,16 @@ class Comparator:
143
146
  self._result = {}
144
147
  self._future = {}
145
148
 
146
- def submit(self, path: Path):
149
+ def submit(self, path: Path) -> None:
147
150
  logger.debug(f"Submitting comparison for path: {path}")
148
151
 
149
152
  if not isinstance(path, Path):
150
153
  raise TypeError("Path must be of type Path")
151
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
+
152
159
  if path in self._future:
153
160
  try:
154
161
  self._future[path].cancel()
@@ -160,7 +167,7 @@ class Comparator:
160
167
  self._result[path] = "pending"
161
168
  self._future[path] = self._executor.submit(self.compare, path)
162
169
 
163
- def compare(self, path: Path):
170
+ def compare(self, path: Path) -> None:
164
171
  logger.debug(f"Comparing files for path: {path}")
165
172
 
166
173
  if not isinstance(path, Path):
@@ -177,7 +184,7 @@ class Comparator:
177
184
  self._result[path] = "same" if result else "different"
178
185
  self._future.pop(path)
179
186
 
180
- def result(self, path: Path):
187
+ def result(self, path: Path) -> str | None:
181
188
  logger.debug(f"Getting comparison result for path: {path}")
182
189
 
183
190
  if not isinstance(path, Path):
@@ -220,9 +227,13 @@ class Comparator:
220
227
 
221
228
  return functools.reduce(
222
229
  lambda a, b: (
223
- "pending"
224
- if "pending" in (a, b)
225
- else ("different" if "different" in (a, b) else "same")
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
+ )
226
237
  ),
227
238
  [self.result(path / name) for name in common]
228
239
  + [
@@ -235,8 +246,8 @@ class Comparator:
235
246
  "same",
236
247
  )
237
248
 
238
- logger.warning(f"No comparison result for path: {path}")
239
- return "unknown"
249
+ logger.debug(f"No comparison result for path: {path}")
250
+ return None
240
251
 
241
252
 
242
253
  app = Flask("compare")
@@ -248,7 +259,7 @@ def root():
248
259
 
249
260
  current_entry_id = 0
250
261
 
251
- def next_entry_id():
262
+ def next_entry_id() -> int:
252
263
  nonlocal current_entry_id
253
264
  entry_id = current_entry_id
254
265
  current_entry_id += 1
@@ -284,7 +295,7 @@ def root():
284
295
  result += "<td></td>"
285
296
 
286
297
  if is_comparable:
287
- main = f'<a href="/compare/{path}">{name}</a>'
298
+ main = f'<a href="/compare/{path}" target="_blank">{name}</a>'
288
299
  else:
289
300
  main = f"{name}"
290
301
  if cmp_result is not None:
@@ -297,6 +308,10 @@ def root():
297
308
  result += f'<td class="main" style="{style}">{main}</td>'
298
309
  result += f"<td>{message}</td>"
299
310
 
311
+ result += (
312
+ f"<td><button onclick=\"updateRef('{path}')\">update ref</button></td>"
313
+ )
314
+
300
315
  result += "</tr>"
301
316
 
302
317
  return result
@@ -435,10 +450,17 @@ tr {
435
450
 
436
451
  result += "<p>"
437
452
  result += "comparing<br>"
438
- result += f"A: {Config.path_a}<br>"
439
- result += f"B: {Config.path_b}"
453
+ result += f"Reference: {Config.path_a}<br>"
454
+ result += f"Monitored: {Config.path_b}"
440
455
  result += "</p>"
441
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
+
442
464
  result += "<p>"
443
465
  result += '<button onclick="toggleAll(true)">Expand All</button>'
444
466
  result += '<button onclick="toggleAll(false)">Collapse All</button>'
@@ -446,7 +468,7 @@ tr {
446
468
 
447
469
  result += "<table>"
448
470
  result += "<thead>"
449
- result += "<tr><td></td><td></td><td>Name</td><td>Message</td></tr>"
471
+ result += "<tr><td></td><td></td><td>Name</td><td>Message</td><td>Actions</td></tr>"
450
472
  result += "</thead>"
451
473
  result += "<tbody>"
452
474
  result += generate_tree(Config.path_a, Config.path_b, "", None, 0)
@@ -485,6 +507,21 @@ function toggleAll(show) {
485
507
  }
486
508
  });
487
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
+ }
488
525
  </script>
489
526
  </body>
490
527
  </html>
@@ -493,6 +530,16 @@ function toggleAll(show) {
493
530
  return result
494
531
 
495
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
+
496
543
  @app.route("/compare/<path:path>")
497
544
  def compare(path: str):
498
545
  logger.debug(f"Generating comparison page for path: {path}")
@@ -509,15 +556,15 @@ html,body {{height:100%;margin:0;}}
509
556
  </head>
510
557
  <body style="display:flex;flex-flow:row;">
511
558
  <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
512
- <a href="/file/a/{path}">{Config.path_a / path}</a>
559
+ <a href="/file/a/{path}" target="_blank">{Config.path_a / path}</a>
513
560
  <iframe id="a" src="/file/a/{path}" title="a" frameborder="0" align="left" style="flex:1;"></iframe>
514
561
  </div>
515
562
  <div style="display:flex;flex:0 0 50px;flex-flow:column;">
516
- <a href="/image_diff/{path}">diff</a>
563
+ <a href="/image_diff/{path}" target="_blank">diff</a>
517
564
  <img src="/image_diff/{path}" width="50" height="0" style="flex:1;">
518
565
  </div>
519
566
  <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
520
- <a href="/file/b/{path}">{Config.path_b / path}</a>
567
+ <a href="/file/b/{path}" target="_blank">{Config.path_b / path}</a>
521
568
  <iframe id="b" src="/file/b/{path}" title="b" frameborder="0" align="right" style="flex:1;"></iframe>
522
569
  </div>
523
570
  <script>
@@ -542,6 +589,9 @@ def image_diff(path: str):
542
589
  if not isinstance(path, str):
543
590
  raise TypeError("Path must be a string")
544
591
 
592
+ if Config.driver is None:
593
+ return "Image diff not available without browser driver", 404
594
+
545
595
  diff, _ = html_render_diff(
546
596
  Config.path_a / path,
547
597
  Config.path_b / path,
@@ -566,15 +616,25 @@ def file(variant: str, path: str):
566
616
  return send_from_directory(variant_root, path)
567
617
 
568
618
 
569
- def setup_logging(verbosity: int):
619
+ def verbosity_to_level(verbosity: int) -> int:
570
620
  if verbosity >= 3:
571
- level = logging.DEBUG
621
+ return logging.DEBUG
572
622
  elif verbosity == 2:
573
- level = logging.INFO
623
+ return logging.INFO
574
624
  elif verbosity == 1:
575
- level = logging.WARNING
625
+ return logging.WARNING
576
626
  else:
577
- level = logging.ERROR
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()
578
638
 
579
639
  formatter = logging.Formatter(
580
640
  fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -582,21 +642,49 @@ def setup_logging(verbosity: int):
582
642
  )
583
643
 
584
644
  console_handler = logging.StreamHandler(sys.stderr)
645
+ console_handler.setLevel(level)
585
646
  console_handler.setFormatter(formatter)
586
-
587
- root_logger = logging.getLogger()
588
- root_logger.setLevel(level)
589
- root_logger.handlers.clear()
590
647
  root_logger.addHandler(console_handler)
591
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
+
592
682
 
593
683
  def main():
594
684
  parser = argparse.ArgumentParser()
595
- parser.add_argument("a", type=Path, help="Path to the first directory")
596
- parser.add_argument("b", type=Path, help="Path to the second directory")
597
- parser.add_argument(
598
- "--driver", choices=["chrome", "firefox", "phantomjs"], default="firefox"
599
- )
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"])
600
688
  parser.add_argument("--max-workers", type=int, default=1)
601
689
  parser.add_argument("--compare", action="store_true")
602
690
  parser.add_argument("--port", type=int, default=5000)
@@ -607,14 +695,25 @@ def main():
607
695
  default=0,
608
696
  help="Increase verbosity (-v, -vv, -vvv)",
609
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
+ )
610
706
  args = parser.parse_args()
611
707
 
612
- setup_logging(args.verbose)
708
+ setup_logging(args.verbose, args.log_file, args.log_file_verbosity)
613
709
 
614
- Config.path_a = args.a
615
- Config.path_b = args.b
710
+ Config.path_a = args.ref
711
+ Config.path_b = args.mon
616
712
  Config.driver = args.driver
617
- Config.browser = get_browser(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
618
717
 
619
718
  if args.compare:
620
719
  Config.comparator = Comparator(max_workers=args.max_workers)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.1.0
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
+ ```
File without changes
File without changes
File without changes