imdiff 0.4.6__tar.gz → 2.0.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.
Files changed (64) hide show
  1. imdiff-2.0.0/PKG-INFO +103 -0
  2. imdiff-2.0.0/README.md +67 -0
  3. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff/__init__.py +5 -1
  4. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff/__main__.py +2 -1
  5. imdiff-2.0.0/imdiff/_compat.py +19 -0
  6. imdiff-2.0.0/imdiff/_types.py +19 -0
  7. imdiff-2.0.0/imdiff/cli/__init__.py +1 -0
  8. imdiff-2.0.0/imdiff/cli/_gui_runtime.py +39 -0
  9. imdiff-2.0.0/imdiff/cli/dir_diff.py +81 -0
  10. imdiff-2.0.0/imdiff/cli/image_diff.py +75 -0
  11. imdiff-2.0.0/imdiff/cli/main.py +91 -0
  12. imdiff-2.0.0/imdiff/directory_comparator.py +71 -0
  13. imdiff-2.0.0/imdiff/gui/__init__.py +6 -0
  14. imdiff-2.0.0/imdiff/gui/_types.py +37 -0
  15. imdiff-2.0.0/imdiff/gui/dir_diff_main_window.py +184 -0
  16. imdiff-2.0.0/imdiff/gui/file_list_frame.py +560 -0
  17. imdiff-2.0.0/imdiff/gui/image_canvas.py +263 -0
  18. imdiff-2.0.0/imdiff/gui/image_diff_main_window.py +86 -0
  19. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff/gui/image_frame.py +171 -122
  20. imdiff-2.0.0/imdiff/gui/image_scaling.py +101 -0
  21. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff/gui/status_bar.py +40 -20
  22. imdiff-2.0.0/imdiff/gui/transient_menu.py +91 -0
  23. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff/gui/zoom_menu.py +31 -8
  24. imdiff-2.0.0/imdiff/image_comparator.py +221 -0
  25. imdiff-2.0.0/imdiff/list_files.py +100 -0
  26. imdiff-2.0.0/imdiff/util.py +97 -0
  27. imdiff-2.0.0/imdiff/version.py +4 -0
  28. imdiff-2.0.0/imdiff.egg-info/PKG-INFO +103 -0
  29. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff.egg-info/SOURCES.txt +14 -1
  30. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff.egg-info/requires.txt +5 -1
  31. {imdiff-0.4.6 → imdiff-2.0.0}/pyproject.toml +39 -6
  32. imdiff-2.0.0/test/test_cli_diff.py +288 -0
  33. imdiff-2.0.0/test/test_cli_main.py +243 -0
  34. imdiff-2.0.0/test/test_directory_comparator.py +115 -0
  35. imdiff-2.0.0/test/test_file_list_frame.py +101 -0
  36. imdiff-2.0.0/test/test_image_comparator.py +236 -0
  37. imdiff-2.0.0/test/test_image_scaling.py +78 -0
  38. imdiff-2.0.0/test/test_list_files.py +134 -0
  39. imdiff-2.0.0/test/test_module_main.py +14 -0
  40. imdiff-2.0.0/test/test_util.py +57 -0
  41. imdiff-0.4.6/PKG-INFO +0 -31
  42. imdiff-0.4.6/README.md +0 -3
  43. imdiff-0.4.6/imdiff/cli/__init__.py +0 -0
  44. imdiff-0.4.6/imdiff/cli/dir_diff.py +0 -46
  45. imdiff-0.4.6/imdiff/cli/image_diff.py +0 -42
  46. imdiff-0.4.6/imdiff/cli/main.py +0 -58
  47. imdiff-0.4.6/imdiff/directory_comparator.py +0 -53
  48. imdiff-0.4.6/imdiff/gui/__init__.py +0 -2
  49. imdiff-0.4.6/imdiff/gui/dir_diff_main_window.py +0 -131
  50. imdiff-0.4.6/imdiff/gui/file_list_frame.py +0 -448
  51. imdiff-0.4.6/imdiff/gui/image_canvas.py +0 -185
  52. imdiff-0.4.6/imdiff/gui/image_diff_main_window.py +0 -54
  53. imdiff-0.4.6/imdiff/gui/image_scaling.py +0 -88
  54. imdiff-0.4.6/imdiff/gui/transient_menu.py +0 -67
  55. imdiff-0.4.6/imdiff/image_comparator.py +0 -166
  56. imdiff-0.4.6/imdiff/list_files.py +0 -64
  57. imdiff-0.4.6/imdiff/util.py +0 -74
  58. imdiff-0.4.6/imdiff/version.py +0 -2
  59. imdiff-0.4.6/imdiff.egg-info/PKG-INFO +0 -31
  60. {imdiff-0.4.6 → imdiff-2.0.0}/LICENSE +0 -0
  61. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff.egg-info/dependency_links.txt +0 -0
  62. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff.egg-info/entry_points.txt +0 -0
  63. {imdiff-0.4.6 → imdiff-2.0.0}/imdiff.egg-info/top_level.txt +0 -0
  64. {imdiff-0.4.6 → imdiff-2.0.0}/setup.cfg +0 -0
imdiff-2.0.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: imdiff
3
+ Version: 2.0.0
4
+ Summary: Compare image files in different directories
5
+ Author-email: "John T. Goetz" <theodore.goetz@gmail.com>
6
+ Project-URL: homepage, https://gitlab.com/johngoetz/imdiff
7
+ Keywords: image-diff,directory-comparison,visual-regression,golden-files,test-artifacts,qa
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Multimedia :: Graphics :: Viewers
22
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy
30
+ Requires-Dist: pillow
31
+ Requires-Dist: ttkbootstrap
32
+ Provides-Extra: dev
33
+ Requires-Dist: ruff; extra == "dev"
34
+ Requires-Dist: basedpyright; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # ImDiff: Compare Directories of Images
38
+
39
+ ImDiff compares either two image files or two directory trees that contain image
40
+ files with matching relative paths. It is intended for workflows where generated
41
+ images need visual review as part of test development, golden-file updates, or
42
+ artifact triage.
43
+
44
+ The project exposes the same comparison engine through two interfaces:
45
+
46
+ - `python -m imdiff LEFT RIGHT` opens a Tk-based review window when the GUI
47
+ stack is available, or warns and falls back to summary mode when it is not.
48
+ - `python -m imdiff --summary LEFT RIGHT` prints a non-interactive summary and
49
+ exits with a non-zero status when differences are found without importing any
50
+ GUI-only modules.
51
+
52
+ When both arguments are directories, ImDiff walks the union of both directory
53
+ trees, pairs files by relative path, and classifies each entry as:
54
+
55
+ - `identical`: byte-identical files or text files with no unified diff output
56
+ - `similar`: images whose normalized RMSE is below the project threshold
57
+ - `different`: images or text files whose contents differ materially
58
+ - `different-size`: image pairs with different dimensions
59
+ - `missing` or `new`: a file exists on only one side
60
+ - `failed-to-load`: a file exists but Pillow could not decode it as an image
61
+
62
+ The interactive directory view combines a file tree, image panes, and file
63
+ operations so a reviewer can inspect left, right, and diff images, switch zoom
64
+ policies, and copy or delete files directly from the comparison session.
65
+
66
+ This utility requires that the Tk Python packages be installed on the system. Hint for
67
+ those running Ubuntu:
68
+
69
+ ```
70
+ apt install python3-pil python3-pil.imagetk python3-tk
71
+ ```
72
+
73
+ The application also depends on `numpy` and `ttkbootstrap` for image math and
74
+ themed Tk widgets.
75
+
76
+ ## Usage
77
+
78
+ Compare two image files:
79
+
80
+ ```bash
81
+ python -m imdiff path/to/expected.png path/to/actual.png
82
+ ```
83
+
84
+ Compare two directories and print a CI-friendly summary:
85
+
86
+ ```bash
87
+ python -m imdiff --summary tests/baseline-images tests/generated-images
88
+ ```
89
+
90
+ When the GUI cannot be imported or Tk cannot start, the default interactive
91
+ command warns on `stderr` and continues in the same summary mode used by
92
+ `--summary`. This makes headless CI environments safe without changing the CLI
93
+ command line.
94
+
95
+ ## Repository Layout
96
+
97
+ - `imdiff/image_comparator.py` contains the lazy comparison model shared by the
98
+ CLI and GUI.
99
+ - `imdiff/list_files.py` pairs files across directory trees.
100
+ - `imdiff/cli/` contains the command-line entry points and summary printing.
101
+ - `imdiff/gui/` contains the Tk windows, canvases, menus, and directory browser.
102
+ - `developing/architecture.md` describes the component relationships in more
103
+ detail.
imdiff-2.0.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # ImDiff: Compare Directories of Images
2
+
3
+ ImDiff compares either two image files or two directory trees that contain image
4
+ files with matching relative paths. It is intended for workflows where generated
5
+ images need visual review as part of test development, golden-file updates, or
6
+ artifact triage.
7
+
8
+ The project exposes the same comparison engine through two interfaces:
9
+
10
+ - `python -m imdiff LEFT RIGHT` opens a Tk-based review window when the GUI
11
+ stack is available, or warns and falls back to summary mode when it is not.
12
+ - `python -m imdiff --summary LEFT RIGHT` prints a non-interactive summary and
13
+ exits with a non-zero status when differences are found without importing any
14
+ GUI-only modules.
15
+
16
+ When both arguments are directories, ImDiff walks the union of both directory
17
+ trees, pairs files by relative path, and classifies each entry as:
18
+
19
+ - `identical`: byte-identical files or text files with no unified diff output
20
+ - `similar`: images whose normalized RMSE is below the project threshold
21
+ - `different`: images or text files whose contents differ materially
22
+ - `different-size`: image pairs with different dimensions
23
+ - `missing` or `new`: a file exists on only one side
24
+ - `failed-to-load`: a file exists but Pillow could not decode it as an image
25
+
26
+ The interactive directory view combines a file tree, image panes, and file
27
+ operations so a reviewer can inspect left, right, and diff images, switch zoom
28
+ policies, and copy or delete files directly from the comparison session.
29
+
30
+ This utility requires that the Tk Python packages be installed on the system. Hint for
31
+ those running Ubuntu:
32
+
33
+ ```
34
+ apt install python3-pil python3-pil.imagetk python3-tk
35
+ ```
36
+
37
+ The application also depends on `numpy` and `ttkbootstrap` for image math and
38
+ themed Tk widgets.
39
+
40
+ ## Usage
41
+
42
+ Compare two image files:
43
+
44
+ ```bash
45
+ python -m imdiff path/to/expected.png path/to/actual.png
46
+ ```
47
+
48
+ Compare two directories and print a CI-friendly summary:
49
+
50
+ ```bash
51
+ python -m imdiff --summary tests/baseline-images tests/generated-images
52
+ ```
53
+
54
+ When the GUI cannot be imported or Tk cannot start, the default interactive
55
+ command warns on `stderr` and continues in the same summary mode used by
56
+ `--summary`. This makes headless CI environments safe without changing the CLI
57
+ command line.
58
+
59
+ ## Repository Layout
60
+
61
+ - `imdiff/image_comparator.py` contains the lazy comparison model shared by the
62
+ CLI and GUI.
63
+ - `imdiff/list_files.py` pairs files across directory trees.
64
+ - `imdiff/cli/` contains the command-line entry points and summary printing.
65
+ - `imdiff/gui/` contains the Tk windows, canvases, menus, and directory browser.
66
+ - `developing/architecture.md` describes the component relationships in more
67
+ detail.
@@ -1,4 +1,8 @@
1
- from .version import __version__, version_info
1
+ """Public package surface for embedding imdiff in other Python code."""
2
+
3
+ # pyright: reportUnusedImport=none
4
+ # ruff: noqa: F401
2
5
 
3
6
  from .directory_comparator import dir_diff
4
7
  from .image_comparator import ImageComparator
8
+ from .version import __version__, version_info
@@ -1,7 +1,8 @@
1
+ """Module entry point for `python -m imdiff`."""
2
+
1
3
  import sys
2
4
 
3
5
  from .cli.main import main
4
6
 
5
-
6
7
  if __name__ == '__main__':
7
8
  sys.exit(main())
@@ -0,0 +1,19 @@
1
+ """Compatibility helpers for typing features that are optional at runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TYPE_CHECKING, TypeVar
7
+
8
+ F = TypeVar('F', bound=Callable[..., object])
9
+
10
+ if TYPE_CHECKING:
11
+ from typing_extensions import override
12
+ else:
13
+
14
+ def override(method: F) -> F:
15
+ """Preserve the decorator shape when `typing_extensions` is unavailable."""
16
+ return method
17
+
18
+
19
+ __all__ = ['override']
@@ -0,0 +1,19 @@
1
+ """Shared type aliases for the comparison layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ from os import PathLike
7
+ from typing import Literal, Union
8
+
9
+ PathInput = Union[str, PathLike[str], pathlib.Path]
10
+ DiffStatus = Literal[
11
+ 'identical',
12
+ 'different-size',
13
+ 'missing',
14
+ 'new',
15
+ 'failed-to-load',
16
+ 'not-found',
17
+ ]
18
+ DiffInfo = Union[DiffStatus, float]
19
+ FileEntry = tuple[str, pathlib.Path, pathlib.Path]
@@ -0,0 +1 @@
1
+ """CLI package for file and directory entry points."""
@@ -0,0 +1,39 @@
1
+ """Helpers for lazy GUI startup and headless fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class GuiUnavailableError(RuntimeError):
7
+ """Raised when GUI support is unavailable but summary mode can continue."""
8
+
9
+
10
+ def format_gui_warning(error: GuiUnavailableError) -> str:
11
+ """Render a concise warning for stderr when falling back to summary mode."""
12
+ return f'warning: GUI unavailable ({error}); running in summary mode'
13
+
14
+
15
+ def normalize_gui_import_error(error: BaseException) -> GuiUnavailableError | None:
16
+ """Convert expected GUI import failures into a fallback-friendly error."""
17
+ if isinstance(error, ModuleNotFoundError):
18
+ module_name = error.name or str(error)
19
+ return GuiUnavailableError(f'missing Python module {module_name}')
20
+
21
+ if isinstance(error, ImportError):
22
+ module_name = getattr(error, 'name', None)
23
+ if module_name:
24
+ return GuiUnavailableError(f'failed to import Python module {module_name}')
25
+ return GuiUnavailableError(str(error))
26
+
27
+ return None
28
+
29
+
30
+ def normalize_gui_startup_error(error: BaseException) -> GuiUnavailableError | None:
31
+ """Convert expected Tk startup failures into a fallback-friendly error."""
32
+ error_type = type(error)
33
+ if error_type.__name__ == 'TclError' and error_type.__module__ in {
34
+ 'tkinter',
35
+ '_tkinter',
36
+ }:
37
+ return GuiUnavailableError(f'Tk startup failed: {error}')
38
+
39
+ return None
@@ -0,0 +1,81 @@
1
+ """CLI adapters for directory comparison modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import importlib
7
+ import pathlib
8
+ from typing import Optional
9
+
10
+ from ..image_comparator import ImageComparator
11
+ from ..list_files import is_image, list_files
12
+ from ._gui_runtime import (
13
+ normalize_gui_import_error,
14
+ normalize_gui_startup_error,
15
+ )
16
+
17
+
18
+ def app(
19
+ leftdir: Optional[pathlib.Path] = None, rightdir: Optional[pathlib.Path] = None
20
+ ) -> None:
21
+ """Open the directory comparison window for the requested roots."""
22
+ try:
23
+ gui_module = importlib.import_module('imdiff.gui')
24
+ DirDiffMainWindow = gui_module.DirDiffMainWindow
25
+ except (ImportError, ModuleNotFoundError) as error:
26
+ gui_error = normalize_gui_import_error(error)
27
+ assert gui_error is not None
28
+ raise gui_error from error
29
+
30
+ try:
31
+ win = DirDiffMainWindow()
32
+ except Exception as error:
33
+ gui_error = normalize_gui_startup_error(error)
34
+ if gui_error is not None:
35
+ raise gui_error from error
36
+ raise
37
+
38
+ win.load(leftdir, rightdir)
39
+ win.mainloop()
40
+
41
+
42
+ def print_diff(leftdir: pathlib.Path, rightdir: pathlib.Path) -> int:
43
+ """Print a summary of directory differences and return the diff count."""
44
+ ndiffs = 0
45
+ assert leftdir.is_dir() and rightdir.is_dir(), 'Items must both be directories'
46
+
47
+ rmse_threshold = 0.02
48
+
49
+ for subpath, left, right in list_files(leftdir, rightdir):
50
+ # Summary mode reuses the same comparison rules as the GUI so CI output
51
+ # matches what a developer will see when opening the same directories.
52
+ if not left.is_file():
53
+ ndiffs += 1
54
+ print(f'Only in {right.parent}: {subpath}')
55
+ elif not right.is_file():
56
+ ndiffs += 1
57
+ print(f'Only in {left.parent}: {subpath}')
58
+ elif is_image(left) and is_image(right):
59
+ icmp = ImageComparator(left, right)
60
+ diff_info = icmp.diff_info
61
+ if diff_info != 'identical':
62
+ if isinstance(diff_info, str):
63
+ ndiffs += 1
64
+ print(f'{diff_info} (as image): {subpath}')
65
+ elif diff_info > rmse_threshold:
66
+ ndiffs += 1
67
+ print(f'image difference NRMSE {diff_info:.4f}: {subpath}')
68
+ else:
69
+ diff_output = list(
70
+ difflib.unified_diff(
71
+ left.read_text().splitlines(),
72
+ right.read_text().splitlines(),
73
+ fromfile=str(left),
74
+ tofile=str(right),
75
+ lineterm='',
76
+ )
77
+ )
78
+ if diff_output:
79
+ ndiffs += 1
80
+ print('\n'.join(diff_output))
81
+ return ndiffs
@@ -0,0 +1,75 @@
1
+ """CLI adapters for single-image comparison modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import importlib
7
+ import pathlib
8
+ from typing import Optional
9
+
10
+ from ..image_comparator import ImageComparator
11
+ from ..list_files import is_image
12
+ from ._gui_runtime import (
13
+ normalize_gui_import_error,
14
+ normalize_gui_startup_error,
15
+ )
16
+
17
+
18
+ def app(
19
+ left: Optional[pathlib.Path] = None, right: Optional[pathlib.Path] = None
20
+ ) -> None:
21
+ """Open the single-image comparison window for the requested files."""
22
+ try:
23
+ gui_module = importlib.import_module('imdiff.gui')
24
+ ImageDiffMainWindow = gui_module.ImageDiffMainWindow
25
+ except (ImportError, ModuleNotFoundError) as error:
26
+ gui_error = normalize_gui_import_error(error)
27
+ assert gui_error is not None
28
+ raise gui_error from error
29
+
30
+ try:
31
+ win = ImageDiffMainWindow()
32
+ except Exception as error:
33
+ gui_error = normalize_gui_startup_error(error)
34
+ if gui_error is not None:
35
+ raise gui_error from error
36
+ raise
37
+
38
+ win.load(left, right)
39
+ win.mainloop()
40
+
41
+
42
+ def print_diff(left: pathlib.Path, right: pathlib.Path) -> int:
43
+ """Print a summary of a single file comparison and return the diff count."""
44
+ ndiffs = 0
45
+ assert left.is_file() and right.is_file(), 'Items must both be files'
46
+
47
+ rmse_threshold = 0.02
48
+ if is_image(left) and is_image(right):
49
+ icmp = ImageComparator(left, right)
50
+ diff_info = icmp.diff_info
51
+ if diff_info != 'identical':
52
+ ndiffs += 1
53
+ if diff_info == 'missing':
54
+ print(f'Only in {left.parent}: {left.name}')
55
+ elif diff_info == 'new':
56
+ print(f'Only in {right.parent}: {right.name}')
57
+ elif isinstance(diff_info, str):
58
+ print(f'Images {left} and {right} differ ({diff_info})')
59
+ elif diff_info > rmse_threshold:
60
+ print(f'Images {left} and {right} differ NRMSE: {diff_info}')
61
+ else:
62
+ diff_output = list(
63
+ difflib.unified_diff(
64
+ left.read_text().splitlines(),
65
+ right.read_text().splitlines(),
66
+ fromfile=str(left),
67
+ tofile=str(right),
68
+ lineterm='',
69
+ )
70
+ )
71
+ if diff_output:
72
+ ndiffs += 1
73
+ print('\n'.join(diff_output))
74
+
75
+ return ndiffs
@@ -0,0 +1,91 @@
1
+ """Command-line dispatch for summary and GUI workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import pathlib
7
+ import sys
8
+ from typing import Optional
9
+
10
+ from ..version import __version__
11
+ from . import dir_diff, image_diff
12
+ from ._gui_runtime import GuiUnavailableError, format_gui_warning
13
+
14
+
15
+ class ParsedArgs(argparse.Namespace):
16
+ """Typed namespace so downstream code can treat parsed paths precisely."""
17
+
18
+ summary: bool = False
19
+ left: pathlib.Path = pathlib.Path('.')
20
+ right: pathlib.Path = pathlib.Path('.')
21
+
22
+
23
+ def parse_args() -> ParsedArgs:
24
+ """Parse the shared CLI flags for file and directory comparison modes."""
25
+ parser = argparse.ArgumentParser(
26
+ prog='imdiff',
27
+ description='Compare images one by one or directory by directory',
28
+ )
29
+ parser.add_argument(
30
+ '--version',
31
+ action='version',
32
+ version=f'%(prog)s {__version__}',
33
+ )
34
+ parser.add_argument(
35
+ '--summary',
36
+ action='store_true',
37
+ help=(
38
+ 'Print diff result and exit. Disables the GUI. '
39
+ 'Exits with non-zero if there are differences.'
40
+ ),
41
+ )
42
+ parser.add_argument(
43
+ 'left',
44
+ default='.',
45
+ type=pathlib.Path,
46
+ help='Image or directory of images to compare.',
47
+ )
48
+ parser.add_argument(
49
+ 'right',
50
+ default='.',
51
+ type=pathlib.Path,
52
+ help='Image or directory of images to compare.',
53
+ )
54
+ return parser.parse_args(namespace=ParsedArgs())
55
+
56
+
57
+ def main() -> Optional[int]:
58
+ """Dispatch to GUI or summary workflows, with summary fallback for headless use."""
59
+ args = parse_args()
60
+ if args.left.is_dir():
61
+ if not args.right.is_dir():
62
+ raise NotADirectoryError(
63
+ 'Paths must be either both image files or both directories'
64
+ )
65
+ if args.summary:
66
+ ndiffs = dir_diff.print_diff(args.left, args.right)
67
+ return int(ndiffs > 0)
68
+ try:
69
+ dir_diff.app(args.left, args.right)
70
+ return None
71
+ except GuiUnavailableError as error:
72
+ print(format_gui_warning(error), file=sys.stderr)
73
+ ndiffs = dir_diff.print_diff(args.left, args.right)
74
+ return int(ndiffs > 0)
75
+
76
+ if args.left.is_file():
77
+ assert args.right.is_file(), (
78
+ 'Paths must be either both image files or both directories'
79
+ )
80
+ if args.summary:
81
+ ndiffs = image_diff.print_diff(args.left, args.right)
82
+ return int(ndiffs > 0)
83
+ try:
84
+ image_diff.app(args.left, args.right)
85
+ return None
86
+ except GuiUnavailableError as error:
87
+ print(format_gui_warning(error), file=sys.stderr)
88
+ ndiffs = image_diff.print_diff(args.left, args.right)
89
+ return int(ndiffs > 0)
90
+
91
+ raise OSError(f'{args.left} is not a file or directory')
@@ -0,0 +1,71 @@
1
+ """Directory-level comparison helpers used by the CLI summary mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import difflib
7
+ import filecmp
8
+ import pathlib
9
+
10
+ from ._types import PathInput
11
+ from .image_comparator import ImageComparator
12
+ from .list_files import is_image, list_files
13
+
14
+
15
+ def dir_diff(
16
+ leftdir: PathInput, rightdir: PathInput
17
+ ) -> collections.defaultdict[str, list[str]]:
18
+ """Group directory entries by comparison status for summary-style reporting.
19
+
20
+ The CLI and any external callers use this function when they need a compact
21
+ classification of an entire directory tree rather than the richer GUI model.
22
+ It mirrors the GUI thresholds so command-line output and interactive review
23
+ describe the same files as identical, similar, or different.
24
+ """
25
+ leftdir_path = pathlib.Path(leftdir)
26
+ rightdir_path = pathlib.Path(rightdir)
27
+ if not leftdir_path.is_dir():
28
+ raise FileNotFoundError(leftdir_path)
29
+ if not rightdir_path.is_dir():
30
+ raise FileNotFoundError(rightdir_path)
31
+
32
+ rmse_threshold = 0.02
33
+
34
+ dinfo: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
35
+ for subpath, left, right in list_files(leftdir_path, rightdir_path):
36
+ # Files are classified in increasing cost order so exact matches and
37
+ # obvious presence/absence cases avoid loading image data.
38
+ if not left.is_file():
39
+ dinfo['new'].append(subpath)
40
+ elif not right.is_file():
41
+ dinfo['missing'].append(subpath)
42
+ elif filecmp.cmp(left, right, shallow=False):
43
+ dinfo['identical'].append(subpath)
44
+ elif is_image(left) and is_image(right):
45
+ icmp = ImageComparator(left, right)
46
+ diff_info = icmp.diff_info
47
+ if isinstance(diff_info, str):
48
+ dinfo[diff_info].append(subpath)
49
+ elif diff_info > rmse_threshold:
50
+ dinfo['different'].append(subpath)
51
+ else:
52
+ dinfo['similar'].append(subpath)
53
+ else:
54
+ try:
55
+ diff_output = list(
56
+ difflib.unified_diff(
57
+ left.read_text().splitlines(),
58
+ right.read_text().splitlines(),
59
+ fromfile=str(left),
60
+ tofile=str(right),
61
+ lineterm='',
62
+ )
63
+ )
64
+ if diff_output:
65
+ dinfo['different'].append(subpath)
66
+ else:
67
+ dinfo['identical'].append(subpath)
68
+ except UnicodeDecodeError:
69
+ dinfo['different'].append(subpath)
70
+
71
+ return dinfo
@@ -0,0 +1,6 @@
1
+ """Public GUI entry points exposed to the CLI layer."""
2
+
3
+ from .dir_diff_main_window import DirDiffMainWindow as DirDiffMainWindow
4
+ from .image_diff_main_window import ImageDiffMainWindow as ImageDiffMainWindow
5
+
6
+ __all__ = ['DirDiffMainWindow', 'ImageDiffMainWindow']
@@ -0,0 +1,37 @@
1
+ """GUI-facing type aliases and protocols shared across widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import tkinter as tk
7
+ from typing import Callable, Literal, Optional, Protocol, Union
8
+
9
+ from PIL import Image
10
+
11
+ from ..image_comparator import ImageComparator
12
+ from .image_scaling import ImageScaling
13
+
14
+ CanvasName = Literal['left', 'right', 'diff']
15
+ ImageData = Optional[Image.Image]
16
+ Event = tk.Event[tk.Misc]
17
+ SelectCallback = Callable[[Optional[ImageComparator]], None]
18
+ UpdateItemPath = Union[str, pathlib.Path, None]
19
+ UpdateItemCallback = Callable[[UpdateItemPath, Optional[ImageComparator]], None]
20
+ ActionCallback = Callable[[], None]
21
+
22
+
23
+ class ZoomCanvas(Protocol):
24
+ scaling: ImageScaling
25
+ scaling_percent: Optional[int]
26
+
27
+
28
+ class ZoomParent(Protocol):
29
+ canvases: dict[CanvasName, ZoomCanvas]
30
+
31
+ def zoom(
32
+ self,
33
+ mode: ImageScaling.Mode,
34
+ factor: float = 1,
35
+ shrink: bool = True,
36
+ expand: bool = True,
37
+ ) -> None: ...