htmlcmp 1.0.17__tar.gz → 1.1.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.0.17
3
+ Version: 1.1.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlcmp"
3
- version = "1.0.17"
3
+ version = "1.1.0"
4
4
  description = "Compare HTML files by rendered output"
5
5
  classifiers = []
6
6
  authors = [
@@ -6,6 +6,7 @@ import sys
6
6
  import argparse
7
7
  import logging
8
8
  import threading
9
+ import functools
9
10
  from pathlib import Path
10
11
  from concurrent.futures import ThreadPoolExecutor
11
12
 
@@ -30,6 +31,32 @@ class Config:
30
31
  thread_local = threading.local()
31
32
 
32
33
 
34
+ def result_symbol(result: str):
35
+ if not isinstance(result, str):
36
+ raise TypeError("Result must be of type str")
37
+
38
+ if result == "pending":
39
+ return "🔄"
40
+ if result == "same":
41
+ return "✔"
42
+ if result == "different":
43
+ return "❌"
44
+ return "⛔"
45
+
46
+
47
+ def result_css(result: str):
48
+ if not isinstance(result, str):
49
+ raise TypeError("Result must be of type str")
50
+
51
+ if result == "pending":
52
+ return "color:blue;"
53
+ if result == "same":
54
+ return "color:green;"
55
+ if result == "different":
56
+ return "color:orange;"
57
+ return "color:red;"
58
+
59
+
33
60
  class Observer:
34
61
  def __init__(self):
35
62
  class Handler(watchdog.events.FileSystemEventHandler):
@@ -40,7 +67,7 @@ class Observer:
40
67
  event_type = event.event_type
41
68
  src_path = Path(event.src_path)
42
69
 
43
- logger.verbose(f"Watchdog event: {event_type} {src_path}")
70
+ logger.debug(f"Watchdog event: {event_type} {src_path}")
44
71
 
45
72
  if event_type not in ["moved", "deleted", "created", "modified"]:
46
73
  return
@@ -64,7 +91,7 @@ class Observer:
64
91
  self._observer.start()
65
92
 
66
93
  def init_compare(a: Path, b: Path):
67
- logger.verbose(f"Initial compare: {a} vs {b}")
94
+ logger.debug(f"Initial compare: {a} vs {b}")
68
95
 
69
96
  if not isinstance(a, Path) or not isinstance(b, Path):
70
97
  raise TypeError("Paths must be of type Path")
@@ -158,37 +185,58 @@ class Comparator:
158
185
 
159
186
  if path in self._result:
160
187
  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
188
 
166
- if not isinstance(path, Path):
167
- raise TypeError("Path must be of type Path")
189
+ if (Config.path_a / path).is_dir():
190
+ a = Config.path_a / path
191
+ b = Config.path_b / path
168
192
 
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 "⛔"
193
+ left = sorted(p.name for p in a.iterdir())
194
+ right = sorted(p.name for p in b.iterdir())
177
195
 
178
- def result_css(self, path: Path):
179
- logger.debug(f"Getting comparison result CSS for path: {path}")
196
+ left_relevant = sorted(
197
+ [
198
+ name
199
+ for name in left
200
+ if (a / name).is_dir()
201
+ or ((a / name).is_file() and comparable_file(a / name))
202
+ ]
203
+ )
204
+ right_relevant = sorted(
205
+ [
206
+ name
207
+ for name in right
208
+ if (b / name).is_dir()
209
+ or ((b / name).is_file() and comparable_file(b / name))
210
+ ]
211
+ )
212
+
213
+ common = [name for name in left_relevant if name in right_relevant]
214
+ left_missing = [
215
+ name for name in right_relevant if name not in left_relevant
216
+ ]
217
+ right_missing = [
218
+ name for name in left_relevant if name not in right_relevant
219
+ ]
180
220
 
181
- if not isinstance(path, Path):
182
- raise TypeError("Path must be of type Path")
221
+ return functools.reduce(
222
+ lambda a, b: (
223
+ "pending"
224
+ if "pending" in (a, b)
225
+ else ("different" if "different" in (a, b) else "same")
226
+ ),
227
+ [self.result(path / name) for name in common]
228
+ + [
229
+ (
230
+ "different"
231
+ if len(left_missing) + len(right_missing) > 0
232
+ else "same"
233
+ )
234
+ ],
235
+ "same",
236
+ )
183
237
 
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;"
238
+ logger.warning(f"No comparison result for path: {path}")
239
+ return "unknown"
192
240
 
193
241
 
194
242
  app = Flask("compare")
@@ -198,7 +246,66 @@ app = Flask("compare")
198
246
  def root():
199
247
  logger.debug("Generating root directory listing")
200
248
 
201
- def print_tree(a: Path, b: Path):
249
+ current_entry_id = 0
250
+
251
+ def next_entry_id():
252
+ nonlocal current_entry_id
253
+ entry_id = current_entry_id
254
+ current_entry_id += 1
255
+ return entry_id
256
+
257
+ def generate_entry(
258
+ entry_id: int,
259
+ parrent_id: int | None,
260
+ depth: int,
261
+ is_directory: bool,
262
+ is_comparable: bool,
263
+ path: Path,
264
+ name: str,
265
+ message: str = "",
266
+ cmp_result: str = None,
267
+ ) -> str:
268
+ result = ""
269
+
270
+ if cmp_result is None and Config.comparator is not None:
271
+ cmp_result = Config.comparator.result(path)
272
+ is_hidden = (
273
+ Config.comparator is not None
274
+ and Config.comparator.result(path.parent) == "same"
275
+ )
276
+ is_collapsed = cmp_result == "same"
277
+
278
+ hidden_str = "hidden" if is_hidden else ""
279
+ result += f'<tr data-entry-id="{entry_id}" data-parent-id="{"" if parrent_id is None else parrent_id}" data-depth="{depth}" {hidden_str}>'
280
+
281
+ if is_directory:
282
+ result += f'<td><button class="toggle">{"▶" if is_collapsed else "▼"}</button></td>'
283
+ else:
284
+ result += "<td></td>"
285
+
286
+ if is_comparable:
287
+ main = f'<a href="/compare/{path}">{name}</a>'
288
+ else:
289
+ main = f"{name}"
290
+ if cmp_result is not None:
291
+ status = result_symbol(cmp_result)
292
+ style = result_css(cmp_result)
293
+ result += f'<td style="{style}">{status}</td>'
294
+ else:
295
+ style = ""
296
+ result += f"<td></td>"
297
+ result += f'<td class="main" style="{style}">{main}</td>'
298
+ result += f"<td>{message}</td>"
299
+
300
+ result += "</tr>"
301
+
302
+ return result
303
+
304
+ def generate_tree(
305
+ a: Path, b: Path, name: str, parrent_id: int | None, depth: int
306
+ ) -> str:
307
+ result = ""
308
+
202
309
  if not isinstance(a, Path) or not isinstance(b, Path):
203
310
  raise TypeError("Paths must be of type Path")
204
311
  if not a.is_dir() or not b.is_dir():
@@ -206,6 +313,17 @@ def root():
206
313
 
207
314
  common_path = a.relative_to(Config.path_a)
208
315
 
316
+ directory_id = next_entry_id()
317
+ result += generate_entry(
318
+ directory_id,
319
+ parrent_id,
320
+ depth,
321
+ True,
322
+ False,
323
+ common_path,
324
+ name + "/",
325
+ )
326
+
209
327
  left = sorted(p.name for p in a.iterdir())
210
328
  right = sorted(p.name for p in b.iterdir())
211
329
 
@@ -230,48 +348,148 @@ def root():
230
348
  common_files = [name for name in left_files if name in right_files]
231
349
  common_dirs = [name for name in left_dirs if name in right_dirs]
232
350
 
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>"
351
+ left_files_missing = [name for name in right_files if name not in left_files]
352
+ right_files_missing = [name for name in left_files if name not in right_files]
353
+ left_dirs_missing = [name for name in right_dirs if name not in left_dirs]
354
+ right_dirs_missing = [name for name in left_dirs if name not in right_dirs]
355
+ for name in left_files_missing:
356
+ result += generate_entry(
357
+ next_entry_id(),
358
+ directory_id,
359
+ depth + 1,
360
+ False,
361
+ False,
362
+ common_path / name,
363
+ name,
364
+ "file missing in A",
365
+ cmp_result="different",
366
+ )
367
+ for name in right_files_missing:
368
+ result += generate_entry(
369
+ next_entry_id(),
370
+ directory_id,
371
+ depth + 1,
372
+ False,
373
+ False,
374
+ common_path / name,
375
+ name,
376
+ "file missing in B",
377
+ cmp_result="different",
378
+ )
379
+ for name in left_dirs_missing:
380
+ result += generate_entry(
381
+ next_entry_id(),
382
+ directory_id,
383
+ depth + 1,
384
+ False,
385
+ False,
386
+ common_path / name,
387
+ name + "/",
388
+ "dir missing in A",
389
+ cmp_result="different",
390
+ )
391
+ for name in right_dirs_missing:
392
+ result += generate_entry(
393
+ next_entry_id(),
394
+ directory_id,
395
+ depth + 1,
396
+ False,
397
+ False,
398
+ common_path / name,
399
+ name + "/",
400
+ "dir missing in B",
401
+ cmp_result="different",
402
+ )
255
403
 
256
404
  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>'
405
+ result += generate_entry(
406
+ next_entry_id(),
407
+ directory_id,
408
+ depth + 1,
409
+ False,
410
+ True,
411
+ common_path / name,
412
+ name,
413
+ "",
414
+ )
263
415
 
264
416
  for name in common_dirs:
265
- result += f"<li>{name}"
266
- result += print_tree(a / name, b / name)
267
- result += "</li>"
417
+ result += generate_tree(a / name, b / name, name, directory_id, depth + 1)
268
418
 
269
- result += "</ul>"
270
419
  return result
271
420
 
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)
421
+ result = """<!DOCTYPE html>
422
+ <html>
423
+ <head>
424
+ <style>
425
+ tr {
426
+ --depth: attr(data-depth number);
427
+ }
428
+ .main {
429
+ padding-left: calc(1.0rem * var(--depth));
430
+ }
431
+ </style>
432
+ </head>
433
+ <body>
434
+ """
435
+
436
+ result += "<p>"
437
+ result += "comparing<br>"
438
+ result += f"A: {Config.path_a}<br>"
439
+ result += f"B: {Config.path_b}"
440
+ result += "</p>"
441
+
442
+ result += "<p>"
443
+ result += '<button onclick="toggleAll(true)">Expand All</button>'
444
+ result += '<button onclick="toggleAll(false)">Collapse All</button>'
445
+ result += "</p>"
446
+
447
+ result += "<table>"
448
+ result += "<thead>"
449
+ result += "<tr><td></td><td></td><td>Name</td><td>Message</td></tr>"
450
+ result += "</thead>"
451
+ result += "<tbody>"
452
+ result += generate_tree(Config.path_a, Config.path_b, "", None, 0)
453
+ result += "</tbody>"
454
+ result += "</table>"
455
+
456
+ result += """
457
+ <script>
458
+ document.addEventListener("click", e => {
459
+ if (!e.target.classList.contains("toggle")) return;
460
+
461
+ const row = e.target.closest("tr");
462
+ const id = row.dataset.entryId;
463
+ const expanded = e.target.textContent === "▼";
464
+
465
+ e.target.textContent = expanded ? "▶" : "▼";
466
+
467
+ toggleChildren(id, !expanded);
468
+ });
469
+
470
+ function toggleChildren(parentId, show) {
471
+ document.querySelectorAll(`tr[data-parent-id="${parentId}"]`)
472
+ .forEach(child => {
473
+ child.hidden = !show;
474
+ });
475
+ }
476
+
477
+ function toggleAll(show) {
478
+ document.querySelectorAll("tr")
479
+ .forEach(row => {
480
+ const isRoot = !row.dataset.parentId;
481
+ row.hidden = !isRoot && !show;
482
+ const toggle = row.querySelector(".toggle");
483
+ if (toggle) {
484
+ toggle.textContent = show ? "▼" : "▶";
485
+ }
486
+ });
487
+ }
488
+ </script>
489
+ </body>
490
+ </html>
491
+ """
492
+
275
493
  return result
276
494
 
277
495
 
@@ -313,7 +531,7 @@ iframe_b.contentWindow.addEventListener('scroll', function(event) {{
313
531
  }});
314
532
  </script>
315
533
  </body>
316
- </html>
534
+ </html>
317
535
  """
318
536
 
319
537
 
@@ -350,13 +568,13 @@ def file(variant: str, path: str):
350
568
 
351
569
  def setup_logging(verbosity: int):
352
570
  if verbosity >= 3:
353
- level = logging.VERBOSE
354
- elif verbosity == 2:
355
571
  level = logging.DEBUG
356
- elif verbosity == 1:
572
+ elif verbosity == 2:
357
573
  level = logging.INFO
358
- else:
574
+ elif verbosity == 1:
359
575
  level = logging.WARNING
576
+ else:
577
+ level = logging.ERROR
360
578
 
361
579
  formatter = logging.Formatter(
362
580
  fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlcmp
3
- Version: 1.0.17
3
+ Version: 1.1.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>
File without changes
File without changes
File without changes