diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py index 6167c80..8ad8c80 100755 --- a/src/htmlcmp/compare_output_server.py +++ b/src/htmlcmp/compare_output_server.py @@ -45,19 +45,6 @@ def result_symbol(result: str) -> str | None: return None -def result_css(result: str) -> str | None: - if not isinstance(result, str): - raise TypeError("Result must be of type str") - - if result == "pending": - return "color:blue;" - if result == "same": - return "color:green;" - if result == "different": - return "color:red;" - return None - - class Observer: def __init__(self): class Handler(watchdog.events.FileSystemEventHandler): @@ -248,6 +235,11 @@ def result(self, path: Path) -> str | None: logger.debug(f"No comparison result for path: {path}") return None + def all_results(self) -> dict[str, str]: + # Snapshot the per-file results as a plain {path: result} map. + # Cheap O(n) dict copy used by the live-status endpoint; no tree walk. + return {str(path): result for path, result in list(self._result.items())} + app = Flask("compare") @@ -278,268 +270,308 @@ def script_js(): def root(): logger.debug("Generating root directory listing") - current_entry_id = 0 - - def next_entry_id() -> int: - nonlocal current_entry_id - entry_id = current_entry_id - current_entry_id += 1 - return entry_id - - def generate_entry( - entry_id: int, - parrent_id: int | None, - depth: int, - is_directory: bool, - is_comparable: bool, - path: Path, - name: str, - message: str = "", - cmp_result: str = None, - ) -> str: - result = "" - - if cmp_result is None and Config.comparator is not None: - cmp_result = Config.comparator.result(path) - is_hidden = ( - Config.comparator is not None - and Config.comparator.result(path.parent) == "same" - ) - is_collapsed = cmp_result == "same" - - hidden_str = "hidden" if is_hidden else "" - result += f'' - - if is_directory: - result += f'' - else: - result += "" - - if is_comparable: - main = f'{name}' - else: - main = f"{name}" - if cmp_result is not None: - status = result_symbol(cmp_result) - style = result_css(cmp_result) - result += f'{status}' - else: - style = "" - result += f"" - result += f'{main}' - result += f"{message}" - - result += ( - f"" - ) - - result += "" + has_comparator = Config.comparator is not None - return result + def collect(a: Path, b: Path) -> list[dict]: + """Single O(n) walk producing flat leaf rows. - def generate_tree( - a: Path, b: Path, name: str, parrent_id: int | None, depth: int - ) -> str: - result = "" - - if not isinstance(a, Path) or not isinstance(b, Path): - raise TypeError("Paths must be of type Path") - if not a.is_dir() or not b.is_dir(): - raise ValueError("Both paths must be directories") + One row per comparable file (and per missing file/dir). File statuses + are O(1) dict lookups, so there is no recursive per-directory status + aggregation and the whole tree is walked exactly once. + """ + entries = [] common_path = a.relative_to(Config.path_a) - directory_id = next_entry_id() - result += generate_entry( - directory_id, - parrent_id, - depth, - True, - False, - common_path, - name + "/", - ) - left = sorted(p.name for p in a.iterdir()) right = sorted(p.name for p in b.iterdir()) - left_files = sorted( - [ - name - for name in left - if (a / name).is_file() and comparable_file(a / name) - ] - ) - right_files = sorted( - [ - name - for name in right - if (b / name).is_file() and comparable_file(b / name) - ] - ) - - left_dirs = sorted([name for name in left if (a / name).is_dir()]) - right_dirs = sorted([name for name in right if (b / name).is_dir()]) - - common_files = [name for name in left_files if name in right_files] - common_dirs = [name for name in left_dirs if name in right_dirs] - - left_files_missing = [name for name in right_files if name not in left_files] - right_files_missing = [name for name in left_files if name not in right_files] - left_dirs_missing = [name for name in right_dirs if name not in left_dirs] - right_dirs_missing = [name for name in left_dirs if name not in right_dirs] - for name in left_files_missing: - result += generate_entry( - next_entry_id(), - directory_id, - depth + 1, - False, - False, - common_path / name, - name, - "file missing in A", - cmp_result="different", - ) - for name in right_files_missing: - result += generate_entry( - next_entry_id(), - directory_id, - depth + 1, - False, - False, - common_path / name, - name, - "file missing in B", - cmp_result="different", - ) - for name in left_dirs_missing: - result += generate_entry( - next_entry_id(), - directory_id, - depth + 1, - False, - False, - common_path / name, - name + "/", - "dir missing in A", - cmp_result="different", - ) - for name in right_dirs_missing: - result += generate_entry( - next_entry_id(), - directory_id, - depth + 1, - False, - False, - common_path / name, - name + "/", - "dir missing in B", - cmp_result="different", + left_files = { + name for name in left if (a / name).is_file() and comparable_file(a / name) + } + right_files = { + name for name in right if (b / name).is_file() and comparable_file(b / name) + } + left_dirs = {name for name in left if (a / name).is_dir()} + right_dirs = {name for name in right if (b / name).is_dir()} + + for name in sorted(left_files | right_files): + rel = common_path / name + if name in left_files and name in right_files: + result = Config.comparator.result(rel) if has_comparator else None + entries.append( + { + "path": str(rel), + "comparable": True, + "message": "", + "result": result, + } + ) + elif name in right_files: + entries.append( + { + "path": str(rel), + "comparable": False, + "message": "missing in reference (A)", + "result": "different", + } + ) + else: + entries.append( + { + "path": str(rel), + "comparable": False, + "message": "missing in monitored (B)", + "result": "different", + } + ) + + for name in sorted(left_dirs ^ right_dirs): + rel = common_path / name + where = "reference (A)" if name in right_dirs else "monitored (B)" + entries.append( + { + "path": str(rel) + "/", + "comparable": False, + "message": f"directory missing in {where}", + "result": "different", + } ) - for name in common_files: - result += generate_entry( - next_entry_id(), - directory_id, - depth + 1, - False, - True, - common_path / name, - name, - "", - ) + for name in sorted(left_dirs & right_dirs): + entries.extend(collect(a / name, b / name)) - for name in common_dirs: - result += generate_tree(a / name, b / name, name, directory_id, depth + 1) + return entries - return result + entries = collect(Config.path_a, Config.path_b) + entries.sort(key=lambda e: e["path"]) - result = """ + def badge(result: str | None) -> str: + if result is None: + return "" + return f'{result_symbol(result) or ""} {result}' + + rows = [] + for e in entries: + path = e["path"] + if e["comparable"]: + name_cell = f'{path}' + else: + name_cell = f"{path}" + rows.append( + f'' + f'{badge(e["result"])}' + f'{name_cell}' + f'{e["message"]}' + f'' + f"" + ) + + log_link = "" + if Config.log_file is not None: + log_link = f'log file' + + head = """ + +compare-html """ - result += "

" - result += "comparing
" - result += f"Reference: {Config.path_a}
" - result += f"Monitored: {Config.path_b}" - result += "

" + header = f"""
+
compare-html
+
+
reference (A): {Config.path_a}
+
monitored (B): {Config.path_b}
+
+
+ +
+ 0 total + 0 same + 0 diff + 0 pending +
+
+ + + + +
+ {log_link} +
+
+""" + + table = ( + "" + "" + "" + "".join(rows) + "
StatusPathMessageActions
" + ) - if Config.log_file is not None: - result += "

" - result += ( - f'View log file: {Config.log_file}' - ) - result += "

" - - result += "

" - result += '' - result += '' - result += "

" - - result += "" - result += "" - result += "" - result += "" - result += "" - result += generate_tree(Config.path_a, Config.path_b, "", None, 0) - result += "" - result += "
NameMessageActions
" - - result += """ + script = ( + """ """ + ) + + return head + header + table + script + + +@app.route("/status") +def status(): + logger.debug("Serving comparison status") + + if Config.comparator is None: + return {} - return result + return Config.comparator.all_results() @app.route("/logfile")