htmlcmp 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Compare HTML files by rendered output
5
5
  Author: Andreas Stefl
6
6
  Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
@@ -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.2.0"
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,20 +246,42 @@ 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")
243
254
 
244
255
 
256
+ @app.route("/script.js")
257
+ def script_js():
258
+ logger.debug("Serving script.js")
259
+
260
+ return """
261
+ function updateRef(path) {
262
+ fetch(`/update_ref/${path}`)
263
+ .then(response => {
264
+ if (response.ok) {
265
+ alert(`Reference updated for ${path}`);
266
+ location.reload();
267
+ } else {
268
+ alert(`Failed to update reference for ${path}: ${response.statusText}`);
269
+ }
270
+ })
271
+ .catch(error => {
272
+ alert(`Error updating reference for ${path}: ${error}`);
273
+ });
274
+ }
275
+ """
276
+
277
+
245
278
  @app.route("/")
246
279
  def root():
247
280
  logger.debug("Generating root directory listing")
248
281
 
249
282
  current_entry_id = 0
250
283
 
251
- def next_entry_id():
284
+ def next_entry_id() -> int:
252
285
  nonlocal current_entry_id
253
286
  entry_id = current_entry_id
254
287
  current_entry_id += 1
@@ -284,7 +317,7 @@ def root():
284
317
  result += "<td></td>"
285
318
 
286
319
  if is_comparable:
287
- main = f'<a href="/compare/{path}">{name}</a>'
320
+ main = f'<a href="/compare/{path}" target="_blank">{name}</a>'
288
321
  else:
289
322
  main = f"{name}"
290
323
  if cmp_result is not None:
@@ -297,6 +330,10 @@ def root():
297
330
  result += f'<td class="main" style="{style}">{main}</td>'
298
331
  result += f"<td>{message}</td>"
299
332
 
333
+ result += (
334
+ f"<td><button onclick=\"updateRef('{path}')\">update ref</button></td>"
335
+ )
336
+
300
337
  result += "</tr>"
301
338
 
302
339
  return result
@@ -429,16 +466,24 @@ tr {
429
466
  padding-left: calc(1.0rem * var(--depth));
430
467
  }
431
468
  </style>
469
+ <script src="/script.js"></script>
432
470
  </head>
433
471
  <body>
434
472
  """
435
473
 
436
474
  result += "<p>"
437
475
  result += "comparing<br>"
438
- result += f"A: {Config.path_a}<br>"
439
- result += f"B: {Config.path_b}"
476
+ result += f"Reference: {Config.path_a}<br>"
477
+ result += f"Monitored: {Config.path_b}"
440
478
  result += "</p>"
441
479
 
480
+ if Config.log_file is not None:
481
+ result += "<p>"
482
+ result += (
483
+ f'<a href="/logfile" target="_blank">View log file: {Config.log_file}</a>'
484
+ )
485
+ result += "</p>"
486
+
442
487
  result += "<p>"
443
488
  result += '<button onclick="toggleAll(true)">Expand All</button>'
444
489
  result += '<button onclick="toggleAll(false)">Collapse All</button>'
@@ -446,7 +491,7 @@ tr {
446
491
 
447
492
  result += "<table>"
448
493
  result += "<thead>"
449
- result += "<tr><td></td><td></td><td>Name</td><td>Message</td></tr>"
494
+ result += "<tr><td></td><td></td><td>Name</td><td>Message</td><td>Actions</td></tr>"
450
495
  result += "</thead>"
451
496
  result += "<tbody>"
452
497
  result += generate_tree(Config.path_a, Config.path_b, "", None, 0)
@@ -471,6 +516,11 @@ function toggleChildren(parentId, show) {
471
516
  document.querySelectorAll(`tr[data-parent-id="${parentId}"]`)
472
517
  .forEach(child => {
473
518
  child.hidden = !show;
519
+ if (!show) {
520
+ toggleChildren(child.dataset.entryId, false);
521
+ const toggle = child.querySelector(".toggle");
522
+ if (toggle) toggle.textContent = "▶";
523
+ }
474
524
  });
475
525
  }
476
526
 
@@ -493,6 +543,16 @@ function toggleAll(show) {
493
543
  return result
494
544
 
495
545
 
546
+ @app.route("/logfile")
547
+ def logfile():
548
+ logger.debug("Serving log file")
549
+
550
+ if Config.log_file is None:
551
+ return "No log file configured", 404
552
+
553
+ return send_from_directory(Config.log_file.parent, Config.log_file.name)
554
+
555
+
496
556
  @app.route("/compare/<path:path>")
497
557
  def compare(path: str):
498
558
  logger.debug(f"Generating comparison page for path: {path}")
@@ -506,18 +566,20 @@ def compare(path: str):
506
566
  <style>
507
567
  html,body {{height:100%;margin:0;}}
508
568
  </style>
569
+ <script src="/script.js"></script>
509
570
  </head>
510
571
  <body style="display:flex;flex-flow:row;">
511
572
  <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
512
- <a href="/file/a/{path}">{Config.path_a / path}</a>
573
+ <a href="/file/a/{path}" target="_blank">{Config.path_a / path}</a>
513
574
  <iframe id="a" src="/file/a/{path}" title="a" frameborder="0" align="left" style="flex:1;"></iframe>
514
575
  </div>
515
576
  <div style="display:flex;flex:0 0 50px;flex-flow:column;">
516
- <a href="/image_diff/{path}">diff</a>
577
+ <a href="/image_diff/{path}" target="_blank">diff</a>
578
+ <button onclick="updateRef('{path}')">▶</button>
517
579
  <img src="/image_diff/{path}" width="50" height="0" style="flex:1;">
518
580
  </div>
519
581
  <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
520
- <a href="/file/b/{path}">{Config.path_b / path}</a>
582
+ <a href="/file/b/{path}" target="_blank">{Config.path_b / path}</a>
521
583
  <iframe id="b" src="/file/b/{path}" title="b" frameborder="0" align="right" style="flex:1;"></iframe>
522
584
  </div>
523
585
  <script>
@@ -542,6 +604,9 @@ def image_diff(path: str):
542
604
  if not isinstance(path, str):
543
605
  raise TypeError("Path must be a string")
544
606
 
607
+ if Config.driver is None:
608
+ return "Image diff not available without browser driver", 404
609
+
545
610
  diff, _ = html_render_diff(
546
611
  Config.path_a / path,
547
612
  Config.path_b / path,
@@ -566,15 +631,46 @@ def file(variant: str, path: str):
566
631
  return send_from_directory(variant_root, path)
567
632
 
568
633
 
569
- def setup_logging(verbosity: int):
634
+ @app.route("/update_ref/<path:path>")
635
+ def update_ref(path: str):
636
+ logger.debug(f"Updating reference for path: {path}")
637
+
638
+ if not isinstance(path, str):
639
+ raise TypeError("Path must be a string")
640
+
641
+ src = Config.path_b / path
642
+ dst = Config.path_a / path
643
+
644
+ if not src.exists():
645
+ return f"Source file does not exist: {src}", 404
646
+
647
+ if src.is_file():
648
+ shutil.copy2(src, dst)
649
+ else:
650
+ shutil.copytree(src, dst, dirs_exist_ok=True)
651
+
652
+ return "Reference updated", 200
653
+
654
+
655
+ def verbosity_to_level(verbosity: int) -> int:
570
656
  if verbosity >= 3:
571
- level = logging.DEBUG
657
+ return logging.DEBUG
572
658
  elif verbosity == 2:
573
- level = logging.INFO
659
+ return logging.INFO
574
660
  elif verbosity == 1:
575
- level = logging.WARNING
661
+ return logging.WARNING
576
662
  else:
577
- level = logging.ERROR
663
+ return logging.ERROR
664
+
665
+
666
+ def setup_logging(
667
+ verbosity: int, log_file: Path = None, log_file_verbosity: int = None
668
+ ) -> None:
669
+ level = verbosity_to_level(verbosity)
670
+
671
+ root_logger = logging.getLogger()
672
+ root_logger.setLevel(level)
673
+ root_logger.handlers.clear()
578
674
 
579
675
  formatter = logging.Formatter(
580
676
  fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -582,21 +678,28 @@ def setup_logging(verbosity: int):
582
678
  )
583
679
 
584
680
  console_handler = logging.StreamHandler(sys.stderr)
681
+ console_handler.setLevel(level)
585
682
  console_handler.setFormatter(formatter)
586
-
587
- root_logger = logging.getLogger()
588
- root_logger.setLevel(level)
589
- root_logger.handlers.clear()
590
683
  root_logger.addHandler(console_handler)
591
684
 
685
+ if log_file is not None:
686
+ file_level = (
687
+ verbosity_to_level(log_file_verbosity)
688
+ if log_file_verbosity is not None
689
+ else level
690
+ )
691
+
692
+ file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
693
+ file_handler.setLevel(file_level)
694
+ file_handler.setFormatter(formatter)
695
+ root_logger.addHandler(file_handler)
696
+
592
697
 
593
698
  def main():
594
699
  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
- )
700
+ parser.add_argument("ref", type=Path, help="Path to the reference directory")
701
+ parser.add_argument("mon", type=Path, help="Path to the monitored directory")
702
+ parser.add_argument("--driver", choices=["chrome", "firefox", "phantomjs"])
600
703
  parser.add_argument("--max-workers", type=int, default=1)
601
704
  parser.add_argument("--compare", action="store_true")
602
705
  parser.add_argument("--port", type=int, default=5000)
@@ -607,14 +710,25 @@ def main():
607
710
  default=0,
608
711
  help="Increase verbosity (-v, -vv, -vvv)",
609
712
  )
713
+ parser.add_argument(
714
+ "--log-file",
715
+ type=Path,
716
+ help="Path to log file",
717
+ )
718
+ parser.add_argument(
719
+ "--log-file-verbosity", type=int, help="Log file verbosity level"
720
+ )
610
721
  args = parser.parse_args()
611
722
 
612
- setup_logging(args.verbose)
723
+ setup_logging(args.verbose, args.log_file, args.log_file_verbosity)
613
724
 
614
- Config.path_a = args.a
615
- Config.path_b = args.b
725
+ Config.path_a = args.ref
726
+ Config.path_b = args.mon
616
727
  Config.driver = args.driver
617
- Config.browser = get_browser(driver=args.driver)
728
+ Config.browser = (
729
+ get_browser(driver=args.driver) if args.driver is not None else None
730
+ )
731
+ Config.log_file = args.log_file
618
732
 
619
733
  if args.compare:
620
734
  Config.comparator = Comparator(max_workers=args.max_workers)
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from htmlcmp.common import bcolors
11
11
 
12
12
 
13
- def tidy_json(path: Path) -> int:
13
+ def tidy_json(path: Path, verbose: bool = False) -> int:
14
14
  if not isinstance(path, Path):
15
15
  raise TypeError("path must be a Path object")
16
16
  if not path.is_file():
@@ -21,10 +21,11 @@ def tidy_json(path: Path) -> int:
21
21
  json.load(f)
22
22
  return 0
23
23
  except ValueError:
24
+ print(f"{bcolors.FAIL}Error: {path} is not a valid JSON file{bcolors.ENDC}")
24
25
  return 1
25
26
 
26
27
 
27
- def tidy_html(path: Path, html_tidy_config: Path = None) -> int:
28
+ def tidy_html(path: Path, html_tidy_config: Path = None, verbose: bool = False) -> int:
28
29
  if not isinstance(path, Path):
29
30
  raise TypeError("path must be a Path object")
30
31
  if not path.is_file():
@@ -41,6 +42,15 @@ def tidy_html(path: Path, html_tidy_config: Path = None) -> int:
41
42
  result = subprocess.run(
42
43
  cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
43
44
  )
45
+ if result.stdout:
46
+ if verbose and result.returncode == 0:
47
+ print(result.stdout)
48
+ elif verbose and result.returncode == 1:
49
+ print(f"{bcolors.WARNING}Warning: {path} has warnings{bcolors.ENDC}")
50
+ print(f"{bcolors.WARNING}{result.stdout}{bcolors.ENDC}")
51
+ elif verbose or result.returncode > 1:
52
+ print(f"{bcolors.FAIL}Error: {path} has errors{bcolors.ENDC}")
53
+ print(f"{bcolors.FAIL}{result.stdout}{bcolors.ENDC}")
44
54
  if result.returncode == 1:
45
55
  return 1
46
56
  if result.returncode > 1:
@@ -48,16 +58,16 @@ def tidy_html(path: Path, html_tidy_config: Path = None) -> int:
48
58
  return 0
49
59
 
50
60
 
51
- def tidy_file(path: Path, html_tidy_config: Path = None) -> int:
61
+ def tidy_file(path: Path, html_tidy_config: Path = None, verbose: bool = False) -> int:
52
62
  if not isinstance(path, Path):
53
63
  raise TypeError("path must be a Path object")
54
64
  if not path.is_file():
55
65
  raise FileNotFoundError(f"{path} is not a file")
56
66
 
57
67
  if path.suffix == ".json":
58
- return tidy_json(path)
68
+ return tidy_json(path, verbose=verbose)
59
69
  elif path.suffix == ".html":
60
- return tidy_html(path, html_tidy_config=html_tidy_config)
70
+ return tidy_html(path, html_tidy_config=html_tidy_config, verbose=verbose)
61
71
 
62
72
 
63
73
  def tidyable_file(path: Path) -> bool:
@@ -74,7 +84,11 @@ def tidyable_file(path: Path) -> bool:
74
84
 
75
85
 
76
86
  def tidy_dir(
77
- path: Path, level: int = 0, prefix: str = "", html_tidy_config: Path = None
87
+ path: Path,
88
+ level: int = 0,
89
+ prefix: str = "",
90
+ html_tidy_config: Path = None,
91
+ verbose: bool = False,
78
92
  ) -> dict[str, list[Path]]:
79
93
  if not isinstance(path, Path):
80
94
  raise TypeError("path must be a Path object")
@@ -104,7 +118,7 @@ def tidy_dir(
104
118
 
105
119
  for filename in [path.name for path in files]:
106
120
  filepath = path / filename
107
- tidy = tidy_file(filepath, html_tidy_config=html_tidy_config)
121
+ tidy = tidy_file(filepath, html_tidy_config=html_tidy_config, verbose=verbose)
108
122
  if tidy == 0:
109
123
  print(f"{prefix_file}{bcolors.OKGREEN}{filename} ✓{bcolors.ENDC}")
110
124
  elif tidy == 1:
@@ -121,6 +135,7 @@ def tidy_dir(
121
135
  level=level + 1,
122
136
  prefix=prefix + "│ ",
123
137
  html_tidy_config=html_tidy_config,
138
+ verbose=verbose,
124
139
  )
125
140
  result["warning"].extend(subresult["warning"])
126
141
  result["error"].extend(subresult["error"])
@@ -134,9 +149,16 @@ def main():
134
149
  parser.add_argument(
135
150
  "--html-tidy-config", type=Path, help="Path to tidy config file"
136
151
  )
152
+ parser.add_argument(
153
+ "--verbose",
154
+ action="store_true",
155
+ help="Print verbose output (warnings and errors)",
156
+ )
137
157
  args = parser.parse_args()
138
158
 
139
- result = tidy_dir(args.path, html_tidy_config=args.html_tidy_config)
159
+ result = tidy_dir(
160
+ args.path, html_tidy_config=args.html_tidy_config, verbose=args.verbose
161
+ )
140
162
  if result["error"]:
141
163
  return 1
142
164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Compare HTML files by rendered output
5
5
  Author: Andreas Stefl
6
6
  Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
@@ -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