htmlcmp 1.0.0__tar.gz → 1.0.2__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.2/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: htmlcmp
3
+ Version: 1.0.2
4
+ Summary: Compare HTML files by rendered output
5
+ Home-page: https://github.com/opendocument-app/compare-html
6
+ Author: Andreas Stefl
7
+ Author-email: stefl.andreas@gmail.com
8
+ Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
9
+ Project-URL: homepage, https://opendocument.app/
10
+ Project-URL: source, https://github.com/opendocument-app/compare-html
11
+ Project-URL: download, https://pypi.org/project/pyodr/#files
12
+ Project-URL: tracker, https://github.com/opendocument-app/compare-html/issues
13
+ Project-URL: release notes, https://github.com/opendocument-app/compare-html/releases
14
+ Requires-Python: >=3.7
15
+ Description-Content-Type: text/markdown
16
+ Dynamic: author-email
17
+ Dynamic: home-page
18
+ Dynamic: requires-python
19
+
20
+ # htmlcmp
21
+
22
+ Tool to compare (generated) HTML files visually and automatically using Selenium.
23
+
24
+ Provides various entry points to run:
25
+ - `compare-html` is a CLI tool to compare two directory structures containing HTML files
26
+ - `compare-html-server` starts a webserver and allows to inspect differences manually
27
+ - `html-render-diff` renders two HTML files and produces images
28
+ - `html-tidy` allows to run HTML tidy on a directory
29
+
30
+ Used for regression testing in https://github.com/opendocument-app/OpenDocument.core.
31
+
32
+ ## Install via PyPI
33
+
34
+ ```bash
35
+ pip install htmlcmp
36
+ ```
37
+
38
+ ## Download and run the docker image
39
+
40
+ ```bash
41
+ docker pull ghcr.io/opendocument-app/odr_core_test
42
+ ```
43
+
44
+ ```bash
45
+ docker run -ti \
46
+ -v $(pwd):/repo \
47
+ -p 8000:8000 \
48
+ ghcr.io/opendocument-app/odr_core_test \
49
+ compare-html-server /repo/REFERENCE /repo/MONITORED --compare --driver firefox --port 8000
50
+ ```
51
+
52
+ ## Manually build the docker image
53
+
54
+ ```bash
55
+ docker build --tag odr_core_test test/docker
56
+ ```
@@ -0,0 +1,37 @@
1
+ # htmlcmp
2
+
3
+ Tool to compare (generated) HTML files visually and automatically using Selenium.
4
+
5
+ Provides various entry points to run:
6
+ - `compare-html` is a CLI tool to compare two directory structures containing HTML files
7
+ - `compare-html-server` starts a webserver and allows to inspect differences manually
8
+ - `html-render-diff` renders two HTML files and produces images
9
+ - `html-tidy` allows to run HTML tidy on a directory
10
+
11
+ Used for regression testing in https://github.com/opendocument-app/OpenDocument.core.
12
+
13
+ ## Install via PyPI
14
+
15
+ ```bash
16
+ pip install htmlcmp
17
+ ```
18
+
19
+ ## Download and run the docker image
20
+
21
+ ```bash
22
+ docker pull ghcr.io/opendocument-app/odr_core_test
23
+ ```
24
+
25
+ ```bash
26
+ docker run -ti \
27
+ -v $(pwd):/repo \
28
+ -p 8000:8000 \
29
+ ghcr.io/opendocument-app/odr_core_test \
30
+ compare-html-server /repo/REFERENCE /repo/MONITORED --compare --driver firefox --port 8000
31
+ ```
32
+
33
+ ## Manually build the docker image
34
+
35
+ ```bash
36
+ docker build --tag odr_core_test test/docker
37
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlcmp"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "Compare HTML files by rendered output"
5
5
  classifiers = []
6
6
  authors = [
@@ -7,7 +7,7 @@ long_description = (this_directory / "README.md").read_text()
7
7
 
8
8
  setup(
9
9
  name="htmlcmp",
10
- version="1.0.0",
10
+ version="1.0.2",
11
11
  author="Andreas Stefl",
12
12
  author_email="stefl.andreas@gmail.com",
13
13
  description="Compare HTML files by rendered output",
File without changes
@@ -0,0 +1,10 @@
1
+ class bcolors:
2
+ HEADER = "\033[95m"
3
+ OKBLUE = "\033[94m"
4
+ OKCYAN = "\033[96m"
5
+ OKGREEN = "\033[92m"
6
+ WARNING = "\033[93m"
7
+ FAIL = "\033[91m"
8
+ ENDC = "\033[0m"
9
+ BOLD = "\033[1m"
10
+ UNDERLINE = "\033[4m"
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import sys
6
+ import argparse
7
+ import json
8
+ import threading
9
+ import filecmp
10
+ from concurrent.futures import ThreadPoolExecutor
11
+
12
+ from htmlcmp.html_render_diff import get_browser, html_render_diff
13
+ from htmlcmp.common import bcolors
14
+
15
+
16
+ class Config:
17
+ thread_local = threading.local()
18
+
19
+
20
+ def parse_json(path):
21
+ with open(path) as f:
22
+ return json.load(f)
23
+
24
+
25
+ def compare_json(a, b):
26
+ json_a = json.dumps(parse_json(a), sort_keys=True)
27
+ json_b = json.dumps(parse_json(b), sort_keys=True)
28
+ return json_a == json_b
29
+
30
+
31
+ def compare_html(a, b, browser=None, diff_output=None):
32
+ if browser is None:
33
+ browser = get_browser()
34
+ diff, (image_a, image_b) = html_render_diff(a, b, browser=browser)
35
+ result = True if diff.getbbox() is None else False
36
+ if diff_output is not None and not result:
37
+ os.makedirs(diff_output, exist_ok=True)
38
+ image_a.save(os.path.join(diff_output, "a.png"))
39
+ image_b.save(os.path.join(diff_output, "b.png"))
40
+ diff.save(os.path.join(diff_output, "diff.png"))
41
+ return result
42
+
43
+
44
+ def compare_files(a, b, **kwargs):
45
+ if filecmp.cmp(a, b):
46
+ return True
47
+ if a.endswith(".json"):
48
+ return compare_json(a, b)
49
+ if a.endswith(".html"):
50
+ return compare_html(a, b, **kwargs)
51
+
52
+
53
+ def comparable_file(path):
54
+ if path.endswith(".json"):
55
+ return True
56
+ if path.endswith(".html"):
57
+ return True
58
+ return False
59
+
60
+
61
+ def submit_compare_dirs(a, b, executor, diff_output=None, **kwargs):
62
+ results = {
63
+ "common_dirs": [],
64
+ "common_files": [],
65
+ "left_files_missing": [],
66
+ "right_files_missing": [],
67
+ "left_dirs_missing": [],
68
+ "right_dirs_missing": [],
69
+ }
70
+
71
+ left = sorted(os.listdir(a))
72
+ right = sorted(os.listdir(b))
73
+
74
+ left_files = sorted(
75
+ [
76
+ name
77
+ for name in left
78
+ if os.path.isfile(os.path.join(a, name))
79
+ and comparable_file(os.path.join(a, name))
80
+ ]
81
+ )
82
+ right_files = sorted(
83
+ [
84
+ name
85
+ for name in right
86
+ if os.path.isfile(os.path.join(b, name))
87
+ and comparable_file(os.path.join(b, name))
88
+ ]
89
+ )
90
+ common_files = [name for name in left_files if name in right_files]
91
+
92
+ def compare(path_a, path_b, diff_output):
93
+ browser = getattr(Config.thread_local, "browser", None)
94
+ return compare_files(path_a, path_b, browser=browser, diff_output=diff_output)
95
+
96
+ for name in common_files:
97
+ future = executor.submit(
98
+ compare,
99
+ os.path.join(a, name),
100
+ os.path.join(b, name),
101
+ diff_output=(
102
+ None if diff_output is None else os.path.join(diff_output, name)
103
+ ),
104
+ )
105
+ results["common_files"].append((name, future))
106
+
107
+ results["left_files_missing"] = [
108
+ name for name in right_files if name not in left_files
109
+ ]
110
+ results["right_files_missing"] = [
111
+ name for name in left_files if name not in right_files
112
+ ]
113
+
114
+ left_dirs = sorted([name for name in left if os.path.isdir(os.path.join(a, name))])
115
+ right_dirs = sorted(
116
+ [name for name in right if os.path.isdir(os.path.join(b, name))]
117
+ )
118
+ common_dirs = [path for path in left_dirs if path in right_dirs]
119
+
120
+ for name in common_dirs:
121
+ sub_results = submit_compare_dirs(
122
+ os.path.join(a, name),
123
+ os.path.join(b, name),
124
+ executor=executor,
125
+ diff_output=(
126
+ None if diff_output is None else os.path.join(diff_output, name)
127
+ ),
128
+ **kwargs,
129
+ )
130
+ results["common_dirs"].append((name, sub_results))
131
+
132
+ results["left_dirs_missing"] = [
133
+ name for name in right_dirs if name not in left_dirs
134
+ ]
135
+ results["right_dirs_missing"] = [
136
+ name for name in left_dirs if name not in right_dirs
137
+ ]
138
+
139
+ return results
140
+
141
+
142
+ def print_results(results, a, b, level=0, prefix=""):
143
+ prefix_file = prefix + "├── "
144
+ if level == 0:
145
+ print(f"compare dir {a} with {b}")
146
+
147
+ result = {
148
+ "left_files_missing": [],
149
+ "right_files_missing": [],
150
+ "left_dirs_missing": [],
151
+ "right_dirs_missing": [],
152
+ "files_different": [],
153
+ }
154
+
155
+ left_files_missing = " ".join(results["left_files_missing"])
156
+ if left_files_missing:
157
+ print(
158
+ f"{prefix_file}{bcolors.FAIL}missing files left: {left_files_missing} ✘{bcolors.ENDC}"
159
+ )
160
+ result["left_files_missing"].extend(
161
+ [os.path.join(a, name) for name in results["left_files_missing"]]
162
+ )
163
+ right_files_missing = " ".join(results["right_files_missing"])
164
+ if right_files_missing:
165
+ print(
166
+ f"{prefix_file}{bcolors.FAIL}missing files right: {right_files_missing} ✘{bcolors.ENDC}"
167
+ )
168
+ result["right_files_missing"].extend(
169
+ [os.path.join(a, name) for name in results["right_files_missing"]]
170
+ )
171
+
172
+ for name, future in results["common_files"]:
173
+ cmp = future.result()
174
+ if cmp:
175
+ print(f"{prefix_file}{bcolors.OKGREEN}{name} ✓{bcolors.ENDC}")
176
+ else:
177
+ print(f"{prefix_file}{bcolors.FAIL}{name} ✘{bcolors.ENDC}")
178
+ result["files_different"].append(os.path.join(a, name))
179
+
180
+ left_dirs_missing = " ".join(results["left_dirs_missing"])
181
+ if left_dirs_missing:
182
+ print(
183
+ f"{prefix_file}{bcolors.FAIL}missing dirs left: {left_dirs_missing} ✘{bcolors.ENDC}"
184
+ )
185
+ result["left_dirs_missing"].extend(
186
+ [os.path.join(a, name) for name in results["left_dirs_missing"]]
187
+ )
188
+ right_dirs_missing = " ".join(results["right_dirs_missing"])
189
+ if right_dirs_missing:
190
+ print(
191
+ f"{prefix_file}{bcolors.FAIL}missing dirs right: {right_dirs_missing} ✘{bcolors.ENDC}"
192
+ )
193
+ result["right_dirs_missing"].extend(
194
+ [os.path.join(a, name) for name in results["right_dirs_missing"]]
195
+ )
196
+
197
+ for name, sub_results in results["common_dirs"]:
198
+ print(prefix + "├── " + name)
199
+ sub_result = print_results(
200
+ sub_results,
201
+ os.path.join(a, name),
202
+ os.path.join(b, name),
203
+ level=level + 1,
204
+ prefix=prefix + "│ ",
205
+ )
206
+ result["left_files_missing"].extend(sub_result["left_files_missing"])
207
+ result["right_files_missing"].extend(sub_result["right_files_missing"])
208
+ result["left_dirs_missing"].extend(sub_result["left_dirs_missing"])
209
+ result["right_dirs_missing"].extend(sub_result["right_dirs_missing"])
210
+ result["files_different"].extend(sub_result["files_different"])
211
+
212
+ return result
213
+
214
+
215
+ def main():
216
+ parser = argparse.ArgumentParser()
217
+ parser.add_argument("a")
218
+ parser.add_argument("b")
219
+ parser.add_argument(
220
+ "--driver", choices=["chrome", "firefox", "phantomjs"], default="firefox"
221
+ )
222
+ parser.add_argument("--diff-output")
223
+ parser.add_argument("--max-workers", type=int, default=1)
224
+ args = parser.parse_args()
225
+
226
+ def initializer():
227
+ browser = getattr(Config.thread_local, "browser", None)
228
+ if browser is None:
229
+ browser = get_browser(driver=args.driver)
230
+ Config.thread_local.browser = browser
231
+
232
+ executor = ThreadPoolExecutor(max_workers=args.max_workers, initializer=initializer)
233
+
234
+ results = submit_compare_dirs(
235
+ args.a, args.b, executor=executor, diff_output=args.diff_output
236
+ )
237
+
238
+ result = print_results(results, args.a, args.b)
239
+ if (
240
+ result["left_files_missing"]
241
+ or result["right_files_missing"]
242
+ or result["left_dirs_missing"]
243
+ or result["right_dirs_missing"]
244
+ or result["files_different"]
245
+ ):
246
+ return 1
247
+
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import sys
6
+ import argparse
7
+ import threading
8
+ import io
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from html_render_diff import get_browser, html_render_diff
11
+ from compare_output import comparable_file, compare_files
12
+ from flask import Flask, send_from_directory, send_file
13
+ import watchdog.observers
14
+ import watchdog.events
15
+
16
+
17
+ class Config:
18
+ path_a = None
19
+ path_b = None
20
+ driver = None
21
+ observer = None
22
+ comparator = None
23
+ browser = None
24
+ thread_local = threading.local()
25
+
26
+
27
+ class Observer:
28
+ def __init__(self):
29
+ class Handler(watchdog.events.FileSystemEventHandler):
30
+ def __init__(self, path):
31
+ self._path = path
32
+
33
+ def dispatch(self, event):
34
+ if event.event_type in ["opened"]:
35
+ return
36
+
37
+ if os.path.isfile(event.src_path):
38
+ Config.comparator.submit(
39
+ os.path.relpath(event.src_path, self._path)
40
+ )
41
+
42
+ self._observer = watchdog.observers.Observer()
43
+ self._observer.schedule(Handler(Config.path_a), Config.path_a, recursive=True)
44
+ self._observer.schedule(Handler(Config.path_b), Config.path_b, recursive=True)
45
+
46
+ def start(self):
47
+ self._observer.start()
48
+
49
+ def init_compare(a, b):
50
+ common_path = os.path.relpath(a, Config.path_a)
51
+
52
+ left = sorted(os.listdir(a))
53
+ right = sorted(os.listdir(b))
54
+
55
+ common = [name for name in left if name in right]
56
+
57
+ for name in common:
58
+ if os.path.isfile(os.path.join(a, name)) and comparable_file(
59
+ os.path.join(a, name)
60
+ ):
61
+ Config.comparator.submit(os.path.join(common_path, name))
62
+ elif os.path.isdir(os.path.join(a, name)):
63
+ init_compare(os.path.join(a, name), os.path.join(b, name))
64
+
65
+ init_compare(Config.path_a, Config.path_b)
66
+
67
+ def stop(self):
68
+ self._observer.stop()
69
+
70
+ def join(self):
71
+ self._observer.join()
72
+
73
+
74
+ class Comparator:
75
+ def __init__(self, max_workers):
76
+ def initializer():
77
+ browser = getattr(Config.thread_local, "browser", None)
78
+ if browser is None:
79
+ browser = get_browser(driver=Config.driver)
80
+ Config.thread_local.browser = browser
81
+
82
+ self._executor = ThreadPoolExecutor(
83
+ max_workers=max_workers, initializer=initializer
84
+ )
85
+ self._result = {}
86
+ self._future = {}
87
+
88
+ def submit(self, path):
89
+ if path in self._future:
90
+ try:
91
+ self._future[path].cancel()
92
+ self._future[path].result()
93
+ self._future.pop(path)
94
+ except Exception:
95
+ pass
96
+
97
+ self._result[path] = "pending"
98
+ self._future[path] = self._executor.submit(self.compare, path)
99
+
100
+ def compare(self, path):
101
+ browser = getattr(Config.thread_local, "browser", None)
102
+ result = compare_files(
103
+ os.path.join(Config.path_a, path),
104
+ os.path.join(Config.path_b, path),
105
+ browser=browser,
106
+ )
107
+ self._result[path] = "same" if result else "different"
108
+ self._future.pop(path)
109
+
110
+ def result(self, path):
111
+ if path in self._result:
112
+ return self._result[path]
113
+ return "unknown"
114
+
115
+ def result_symbol(self, path):
116
+ result = self.result(path)
117
+ if result == "pending":
118
+ return "🔄"
119
+ if result == "same":
120
+ return "✔"
121
+ if result == "different":
122
+ return "❌"
123
+ return "⛔"
124
+
125
+ def result_css(self, path):
126
+ result = self.result(path)
127
+ if result == "pending":
128
+ return "color:blue;"
129
+ if result == "same":
130
+ return "color:green;"
131
+ if result == "different":
132
+ return "color:orange;"
133
+ return "color:red;"
134
+
135
+
136
+ app = Flask("compare")
137
+
138
+
139
+ @app.route("/")
140
+ def root():
141
+ def print_tree(a, b):
142
+ common_path = os.path.relpath(a, Config.path_a)
143
+
144
+ left = sorted(os.listdir(a))
145
+ right = sorted(os.listdir(b))
146
+
147
+ left_files = sorted(
148
+ [
149
+ name
150
+ for name in left
151
+ if os.path.isfile(os.path.join(a, name))
152
+ and comparable_file(os.path.join(a, name))
153
+ ]
154
+ )
155
+ right_files = sorted(
156
+ [
157
+ name
158
+ for name in right
159
+ if os.path.isfile(os.path.join(b, name))
160
+ and comparable_file(os.path.join(b, name))
161
+ ]
162
+ )
163
+
164
+ left_dirs = sorted(
165
+ [name for name in left if os.path.isdir(os.path.join(a, name))]
166
+ )
167
+ right_dirs = sorted(
168
+ [name for name in right if os.path.isdir(os.path.join(b, name))]
169
+ )
170
+
171
+ common_files = [name for name in left_files if name in right_files]
172
+ common_dirs = [name for name in left_dirs if name in right_dirs]
173
+
174
+ result = "<ul>"
175
+
176
+ left_files_missing = " ".join(
177
+ [name for name in right_files if name not in left_files]
178
+ )
179
+ right_files_missing = " ".join(
180
+ [name for name in left_files if name not in right_files]
181
+ )
182
+ left_dirs_missing = " ".join(
183
+ [name for name in right_dirs if name not in left_dirs]
184
+ )
185
+ right_dirs_missing = " ".join(
186
+ [name for name in left_dirs if name not in right_dirs]
187
+ )
188
+ if left_files_missing:
189
+ result += f"<li><b>A files missing: {left_files_missing}</b></li>"
190
+ if right_files_missing:
191
+ result += f"<li><b>B files missing: {right_files_missing}</b></li>"
192
+ if left_dirs_missing:
193
+ result += f"<li><b>A dirs missing: {left_dirs_missing}</b></li>"
194
+ if right_dirs_missing:
195
+ result += f"<li><b>B dirs missing: {right_dirs_missing}</b></li>"
196
+
197
+ for name in common_files:
198
+ if Config.comparator is None:
199
+ result += f'<li><a href="/compare/{os.path.join(common_path, name)}">{name}</a></li>'
200
+ else:
201
+ symbol = Config.comparator.result_symbol(
202
+ os.path.join(common_path, name)
203
+ )
204
+ css = Config.comparator.result_css(os.path.join(common_path, name))
205
+ result += f'<li style="{css}"><a style="{css}" href="/compare/{os.path.join(common_path, name)}">{name}</a> {symbol}</li>'
206
+
207
+ for name in common_dirs:
208
+ result += f"<li>{name}"
209
+ result += print_tree(os.path.join(a, name), os.path.join(b, name))
210
+ result += "</li>"
211
+
212
+ result += "</ul>"
213
+ return result
214
+
215
+ result = ""
216
+ result += f"<p>compare A {Config.path_a} vs B {Config.path_b}</p>"
217
+ result += print_tree(Config.path_a, Config.path_b)
218
+ return result
219
+
220
+
221
+ @app.route("/compare/<path:path>")
222
+ def compare(path):
223
+ return f"""<!DOCTYPE html>
224
+ <html>
225
+ <head>
226
+ <style>
227
+ html,body {{height:100%;margin:0;}}
228
+ </style>
229
+ </head>
230
+ <body style="display:flex;flex-flow:row;">
231
+ <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
232
+ <a href="/file/a/{path}">{os.path.join(Config.path_a, path)}</a>
233
+ <iframe id="a" src="/file/a/{path}" title="a" frameborder="0" align="left" style="flex:1;"></iframe>
234
+ </div>
235
+ <div style="display:flex;flex:0 0 50px;flex-flow:column;">
236
+ <a href="/image_diff/{path}">diff</a>
237
+ <img src="/image_diff/{path}" width="50" height="0" style="flex:1;">
238
+ </div>
239
+ <div style="display:flex;flex:1;flex-flow:column;margin:5px;">
240
+ <a href="/file/b/{path}">{os.path.join(Config.path_b, path)}</a>
241
+ <iframe id="b" src="/file/b/{path}" title="b" frameborder="0" align="right" style="flex:1;"></iframe>
242
+ </div>
243
+ <script>
244
+ var iframe_a = document.getElementById('a');
245
+ var iframe_b = document.getElementById('b');
246
+ iframe_a.contentWindow.addEventListener('scroll', function(event) {{
247
+ iframe_b.contentWindow.scrollTo(iframe_a.contentWindow.scrollX, iframe_a.contentWindow.scrollY);
248
+ }});
249
+ iframe_b.contentWindow.addEventListener('scroll', function(event) {{
250
+ iframe_a.contentWindow.scrollTo(iframe_b.contentWindow.scrollX, iframe_b.contentWindow.scrollY);
251
+ }});
252
+ </script>
253
+ </body>
254
+ </html>
255
+ """
256
+
257
+
258
+ @app.route("/image_diff/<path:path>")
259
+ def image_diff(path):
260
+ diff, _ = html_render_diff(
261
+ os.path.join(Config.path_a, path),
262
+ os.path.join(Config.path_b, path),
263
+ Config.browser,
264
+ )
265
+ tmp = io.BytesIO()
266
+ diff.save(tmp, "JPEG", quality=70)
267
+ tmp.seek(0)
268
+ return send_file(tmp, mimetype="image/jpeg")
269
+
270
+
271
+ @app.route("/file/<variant>/<path:path>")
272
+ def file(variant, path):
273
+ variant_root = Config.path_a if variant == "a" else Config.path_b
274
+ return send_from_directory(variant_root, path)
275
+
276
+
277
+ def main():
278
+ parser = argparse.ArgumentParser()
279
+ parser.add_argument("a")
280
+ parser.add_argument("b")
281
+ parser.add_argument(
282
+ "--driver", choices=["chrome", "firefox", "phantomjs"], default="firefox"
283
+ )
284
+ parser.add_argument("--max-workers", type=int, default=1)
285
+ parser.add_argument("--compare", action="store_true")
286
+ parser.add_argument("--port", type=int, default=5000)
287
+ args = parser.parse_args()
288
+
289
+ Config.path_a = args.a
290
+ Config.path_b = args.b
291
+ Config.driver = args.driver
292
+ Config.browser = get_browser(driver=args.driver)
293
+
294
+ if args.compare:
295
+ Config.comparator = Comparator(max_workers=args.max_workers)
296
+
297
+ Config.observer = Observer()
298
+ Config.observer.start()
299
+
300
+ app.run(host="0.0.0.0", port=args.port)
301
+
302
+ if args.compare:
303
+ Config.observer.stop()
304
+ Config.observer.join()
305
+
306
+ return 0
307
+
308
+
309
+ if __name__ == "__main__":
310
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import sys
6
+ import argparse
7
+ import io
8
+ import time
9
+
10
+ from PIL import Image, ImageChops
11
+ from selenium import webdriver
12
+ from selenium.webdriver.common.by import By
13
+ from selenium.webdriver.support import expected_conditions
14
+ from selenium.webdriver.support.ui import WebDriverWait
15
+ import pathlib
16
+
17
+
18
+ def to_url(something):
19
+ if os.path.isfile(something):
20
+ return pathlib.Path(os.path.abspath(something)).as_uri()
21
+ return something
22
+
23
+
24
+ def screenshot(browser, url):
25
+ browser.get(url)
26
+
27
+ target_find_by = By.TAG_NAME
28
+ target = "body"
29
+ loaded_page_settling_time = 0
30
+
31
+ # Selenium doesn't like when we try to screenshot <body> element of documents generated by pdf2htmlEX
32
+ if "poppler" in url:
33
+ target_find_by = By.ID
34
+ target = "page-container"
35
+ loaded_page_settling_time = 1
36
+
37
+ web_driver_wait = WebDriverWait(browser, 5)
38
+ web_driver_wait.until(
39
+ expected_conditions.presence_of_element_located((target_find_by, target))
40
+ )
41
+ web_driver_wait.until(
42
+ lambda driver: driver.execute_script("return document.readyState") == "complete"
43
+ )
44
+ if loaded_page_settling_time != 0:
45
+ time.sleep(loaded_page_settling_time)
46
+
47
+ target_element = browser.find_element(target_find_by, target)
48
+
49
+ png = target_element.screenshot_as_png
50
+ return Image.open(io.BytesIO(png))
51
+
52
+
53
+ def get_browser(driver="firefox", max_width=1000, max_height=10000):
54
+ if driver == "phantomjs":
55
+ browser = webdriver.PhantomJS()
56
+ elif driver == "firefox":
57
+ options = webdriver.FirefoxOptions()
58
+ options.add_argument("--headless")
59
+ browser = webdriver.Firefox(options=options)
60
+ else: # chrome or unknown
61
+ options = webdriver.ChromeOptions()
62
+ options.add_argument("--headless=new")
63
+ browser = webdriver.Chrome(options=options)
64
+ browser.set_window_size(max_width, max_height)
65
+ return browser
66
+
67
+
68
+ def html_render_diff(a, b, browser):
69
+ image_a = screenshot(browser, to_url(a))
70
+ image_b = screenshot(browser, to_url(b))
71
+
72
+ image_a = image_a.convert("RGB")
73
+ image_b = image_b.convert("RGB")
74
+ diff = ImageChops.difference(image_a, image_b)
75
+ return diff, (image_a, image_b)
76
+
77
+
78
+ def main():
79
+ parser = argparse.ArgumentParser()
80
+ parser.add_argument("a")
81
+ parser.add_argument("b")
82
+ parser.add_argument(
83
+ "--driver", choices=["chrome", "firefox", "phantomjs"], default="firefox"
84
+ )
85
+ parser.add_argument("--max-width", default=1000)
86
+ parser.add_argument("--max-height", default=10000)
87
+ args = parser.parse_args()
88
+
89
+ browser = get_browser(args.driver, args.max_width, args.max_height)
90
+ diff, _ = html_render_diff(args.a, args.b, browser)
91
+ browser.quit()
92
+ bounding_box = diff.getbbox()
93
+
94
+ if bounding_box:
95
+ print("images are different")
96
+ print("first error at %f%%" % (1e2 * bounding_box[1] / diff.height))
97
+ print(
98
+ "bounding box %f%%"
99
+ % (
100
+ 1e2
101
+ * (
102
+ (bounding_box[2] - bounding_box[0])
103
+ * (bounding_box[3] - bounding_box[1])
104
+ )
105
+ / (diff.width * diff.height)
106
+ )
107
+ )
108
+ return 1
109
+
110
+ print("images are the same")
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import sys
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import shlex
10
+
11
+ from htmlcmp.common import bcolors
12
+
13
+
14
+ def tidy_json(path):
15
+ try:
16
+ with open(path, "r") as f:
17
+ json.load(f)
18
+ return 0
19
+ except ValueError:
20
+ return 1
21
+
22
+
23
+ def tidy_html(path):
24
+ config_path = os.path.join(
25
+ os.path.dirname(os.path.realpath(__file__)), ".html-tidy"
26
+ )
27
+ cmd = shlex.split(f'tidy -config "{config_path}" -q "{path}"')
28
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
29
+ if result.returncode == 1:
30
+ return 1
31
+ if result.returncode > 1:
32
+ return 2
33
+ return 0
34
+
35
+
36
+ def tidy_file(path):
37
+ if path.endswith(".json"):
38
+ return tidy_json(path)
39
+ elif path.endswith(".html"):
40
+ return tidy_html(path)
41
+
42
+
43
+ def tidyable_file(path):
44
+ if path.endswith(".json"):
45
+ return True
46
+ if path.endswith(".html"):
47
+ return True
48
+ return False
49
+
50
+
51
+ def tidy_dir(path, level=0, prefix=""):
52
+ prefix_file = prefix + "├── "
53
+ if level == 0:
54
+ print(f"tidy dir {path}")
55
+
56
+ result = {
57
+ "warning": [],
58
+ "error": [],
59
+ }
60
+
61
+ items = [os.path.join(path, name) for name in os.listdir(path)]
62
+ files = sorted(
63
+ [path for path in items if os.path.isfile(path) and tidyable_file(path)]
64
+ )
65
+ dirs = sorted([path for path in items if os.path.isdir(path)])
66
+
67
+ for filename in [os.path.basename(path) for path in files]:
68
+ filepath = os.path.join(path, filename)
69
+ tidy = tidy_file(filepath)
70
+ if tidy == 0:
71
+ print(f"{prefix_file}{bcolors.OKGREEN}{filename} ✓{bcolors.ENDC}")
72
+ elif tidy == 1:
73
+ print(f"{prefix_file}{bcolors.WARNING}{filename} ✓{bcolors.ENDC}")
74
+ result["warning"].append(filepath)
75
+ elif tidy > 1:
76
+ print(f"{prefix_file}{bcolors.FAIL}{filename} ✘{bcolors.ENDC}")
77
+ result["error"].append(filepath)
78
+
79
+ for dirname in [os.path.basename(path) for path in dirs]:
80
+ print(prefix + "├── " + dirname)
81
+ subresult = tidy_dir(
82
+ os.path.join(path, dirname), level=level + 1, prefix=prefix + "│ "
83
+ )
84
+ result["warning"].extend(subresult["warning"])
85
+ result["error"].extend(subresult["error"])
86
+
87
+ return result
88
+
89
+
90
+ def main():
91
+ parser = argparse.ArgumentParser()
92
+ parser.add_argument("a")
93
+ args = parser.parse_args()
94
+
95
+ result = tidy_dir(args.a)
96
+ if result["error"]:
97
+ return 1
98
+
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: htmlcmp
3
+ Version: 1.0.2
4
+ Summary: Compare HTML files by rendered output
5
+ Home-page: https://github.com/opendocument-app/compare-html
6
+ Author: Andreas Stefl
7
+ Author-email: stefl.andreas@gmail.com
8
+ Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
9
+ Project-URL: homepage, https://opendocument.app/
10
+ Project-URL: source, https://github.com/opendocument-app/compare-html
11
+ Project-URL: download, https://pypi.org/project/pyodr/#files
12
+ Project-URL: tracker, https://github.com/opendocument-app/compare-html/issues
13
+ Project-URL: release notes, https://github.com/opendocument-app/compare-html/releases
14
+ Requires-Python: >=3.7
15
+ Description-Content-Type: text/markdown
16
+ Dynamic: author-email
17
+ Dynamic: home-page
18
+ Dynamic: requires-python
19
+
20
+ # htmlcmp
21
+
22
+ Tool to compare (generated) HTML files visually and automatically using Selenium.
23
+
24
+ Provides various entry points to run:
25
+ - `compare-html` is a CLI tool to compare two directory structures containing HTML files
26
+ - `compare-html-server` starts a webserver and allows to inspect differences manually
27
+ - `html-render-diff` renders two HTML files and produces images
28
+ - `html-tidy` allows to run HTML tidy on a directory
29
+
30
+ Used for regression testing in https://github.com/opendocument-app/OpenDocument.core.
31
+
32
+ ## Install via PyPI
33
+
34
+ ```bash
35
+ pip install htmlcmp
36
+ ```
37
+
38
+ ## Download and run the docker image
39
+
40
+ ```bash
41
+ docker pull ghcr.io/opendocument-app/odr_core_test
42
+ ```
43
+
44
+ ```bash
45
+ docker run -ti \
46
+ -v $(pwd):/repo \
47
+ -p 8000:8000 \
48
+ ghcr.io/opendocument-app/odr_core_test \
49
+ compare-html-server /repo/REFERENCE /repo/MONITORED --compare --driver firefox --port 8000
50
+ ```
51
+
52
+ ## Manually build the docker image
53
+
54
+ ```bash
55
+ docker build --tag odr_core_test test/docker
56
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ src/htmlcmp/__init__.py
5
+ src/htmlcmp/common.py
6
+ src/htmlcmp/compare_output.py
7
+ src/htmlcmp/compare_output_server.py
8
+ src/htmlcmp/html_render_diff.py
9
+ src/htmlcmp/tidy_output.py
10
+ src/htmlcmp.egg-info/PKG-INFO
11
+ src/htmlcmp.egg-info/SOURCES.txt
12
+ src/htmlcmp.egg-info/dependency_links.txt
13
+ src/htmlcmp.egg-info/entry_points.txt
14
+ src/htmlcmp.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ htmlcmp
htmlcmp-1.0.0/PKG-INFO DELETED
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: htmlcmp
3
- Version: 1.0.0
4
- Summary: Compare HTML files by rendered output
5
- Home-page: https://github.com/opendocument-app/compare-html
6
- Author: Andreas Stefl
7
- Author-email: stefl.andreas@gmail.com
8
- Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
9
- Project-URL: homepage, https://opendocument.app/
10
- Project-URL: source, https://github.com/opendocument-app/compare-html
11
- Project-URL: download, https://pypi.org/project/pyodr/#files
12
- Project-URL: tracker, https://github.com/opendocument-app/compare-html/issues
13
- Project-URL: release notes, https://github.com/opendocument-app/compare-html/releases
14
- Requires-Python: >=3.7
15
- Description-Content-Type: text/markdown
16
- Dynamic: author-email
17
- Dynamic: home-page
18
- Dynamic: requires-python
19
-
20
- # comare-html
21
-
22
- originally moved out of https://github.com/opendocument-app/OpenDocument.core
23
-
24
- ## Manually build the docker image
25
-
26
- ```bash
27
- docker build --tag odr_core_test test/docker
28
- ```
htmlcmp-1.0.0/README.md DELETED
@@ -1,9 +0,0 @@
1
- # comare-html
2
-
3
- originally moved out of https://github.com/opendocument-app/OpenDocument.core
4
-
5
- ## Manually build the docker image
6
-
7
- ```bash
8
- docker build --tag odr_core_test test/docker
9
- ```
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: htmlcmp
3
- Version: 1.0.0
4
- Summary: Compare HTML files by rendered output
5
- Home-page: https://github.com/opendocument-app/compare-html
6
- Author: Andreas Stefl
7
- Author-email: stefl.andreas@gmail.com
8
- Maintainer-email: Andreas Stefl <stefl.andreas@gmail.com>
9
- Project-URL: homepage, https://opendocument.app/
10
- Project-URL: source, https://github.com/opendocument-app/compare-html
11
- Project-URL: download, https://pypi.org/project/pyodr/#files
12
- Project-URL: tracker, https://github.com/opendocument-app/compare-html/issues
13
- Project-URL: release notes, https://github.com/opendocument-app/compare-html/releases
14
- Requires-Python: >=3.7
15
- Description-Content-Type: text/markdown
16
- Dynamic: author-email
17
- Dynamic: home-page
18
- Dynamic: requires-python
19
-
20
- # comare-html
21
-
22
- originally moved out of https://github.com/opendocument-app/OpenDocument.core
23
-
24
- ## Manually build the docker image
25
-
26
- ```bash
27
- docker build --tag odr_core_test test/docker
28
- ```
@@ -1,8 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- setup.py
4
- src/htmlcmp.egg-info/PKG-INFO
5
- src/htmlcmp.egg-info/SOURCES.txt
6
- src/htmlcmp.egg-info/dependency_links.txt
7
- src/htmlcmp.egg-info/entry_points.txt
8
- src/htmlcmp.egg-info/top_level.txt
File without changes