From b25f72629fdecef95c991df3830eb91f566074a8 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Sat, 10 May 2025 11:16:10 +0200 Subject: [PATCH 1/4] Colored view --- CHANGELOG.md | 3 + deepdiff/colored_view.py | 124 ++++++++++++++++++++++++++++++++ deepdiff/commands.py | 6 +- deepdiff/diff.py | 15 +++- deepdiff/helper.py | 1 + docs/colored_view.rst | 60 ++++++++++++++++ docs/index.rst | 1 + tests/test_colored_view.py | 140 +++++++++++++++++++++++++++++++++++++ 8 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 deepdiff/colored_view.py create mode 100644 docs/colored_view.rst create mode 100644 tests/test_colored_view.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd2a12a..3c6b532f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # DeepDiff Change log +- [Unreleased] + - Colored View: Output pretty-printed JSON with color-coded differences (added in green, removed in red, changed values show old in red and new in green). + - v8-5-0 - Updating deprecated pydantic calls - Switching to pyproject.toml diff --git a/deepdiff/colored_view.py b/deepdiff/colored_view.py new file mode 100644 index 00000000..c946f008 --- /dev/null +++ b/deepdiff/colored_view.py @@ -0,0 +1,124 @@ +import json +from ast import literal_eval +from importlib.util import find_spec +from typing import Any, Dict + +if find_spec("colorama"): + import colorama + + colorama.init() + + +# ANSI color codes +RED = '\033[31m' +GREEN = '\033[32m' +RESET = '\033[0m' + +class ColoredView: + """A view that shows JSON with color-coded differences.""" + + def __init__(self, t2, tree_results, verbose_level=1): + self.t2 = t2 + self.tree = tree_results + self.verbose_level = verbose_level + self.diff_paths = self._collect_diff_paths() + + def _collect_diff_paths(self) -> Dict[str, str]: + """Collect all paths that have differences and their types.""" + diff_paths = {} + for diff_type, items in self.tree.items(): + try: + iterator = iter(items) + except TypeError: + continue + for item in items: + if type(item).__name__ == "DiffLevel": + path = item.path() + if diff_type in ('values_changed', 'type_changes'): + diff_paths[path] = ('changed', item.t1, item.t2) + elif diff_type in ('dictionary_item_added', 'iterable_item_added', 'set_item_added'): + diff_paths[path] = ('added', None, item.t2) + elif diff_type in ('dictionary_item_removed', 'iterable_item_removed', 'set_item_removed'): + diff_paths[path] = ('removed', item.t1, None) + return diff_paths + + def _format_value(self, value: Any) -> str: + """Format a value for display.""" + if isinstance(value, bool): + return 'true' if value else 'false' + elif isinstance(value, str): + return f'"{value}"' + elif isinstance(value, (dict, list, tuple)): + return json.dumps(value) + else: + return str(value) + + def _get_path_removed(self, path: str) -> dict: + """Get all removed items for a given path.""" + removed = {} + for key, value in self.diff_paths.items(): + if value[0] == 'removed' and key.startswith(path + "["): + key_suffix = key[len(path):] + if key_suffix.count("[") == 1 and key_suffix.endswith("]"): + removed[literal_eval(key_suffix[1:-1])] = value[1] + return removed + + def _colorize_json(self, obj: Any, path: str = 'root', indent: int = 0) -> str: + """Recursively colorize JSON based on differences, with pretty-printing.""" + INDENT = ' ' + current_indent = INDENT * indent + next_indent = INDENT * (indent + 1) + if path in self.diff_paths and path not in self._colorize_skip_paths: + diff_type, old, new = self.diff_paths[path] + if diff_type == 'changed': + return f"{RED}{self._format_value(old)}{RESET} -> {GREEN}{self._format_value(new)}{RESET}" + elif diff_type == 'added': + return f"{GREEN}{self._format_value(new)}{RESET}" + elif diff_type == 'removed': + return f"{RED}{self._format_value(old)}{RESET}" + + if isinstance(obj, dict): + if not obj: + return '{}' + items = [] + for key, value in obj.items(): + new_path = f"{path}['{key}']" if isinstance(key, str) else f"{path}[{key}]" + if new_path in self.diff_paths and self.diff_paths[new_path][0] == 'added': + # Colorize both key and value for added fields + items.append(f'{next_indent}{GREEN}"{key}": {self._colorize_json(value, new_path, indent + 1)}{RESET}') + else: + items.append(f'{next_indent}"{key}": {self._colorize_json(value, new_path, indent + 1)}') + for key, value in self._get_path_removed(path).items(): + new_path = f"{path}['{key}']" if isinstance(key, str) else f"{path}[{key}]" + items.append(f'{next_indent}{RED}"{key}": {self._colorize_json(value, new_path, indent + 1)}{RESET}') + return '{\n' + ',\n'.join(items) + f'\n{current_indent}' + '}' + + elif isinstance(obj, (list, tuple)): + if not obj: + return '[]' + removed_map = self._get_path_removed(path) + for index in removed_map: + self._colorize_skip_paths.add(f"{path}[{index}]") + items = [] + index = 0 + for value in obj: + new_path = f"{path}[{index}]" + while index == next(iter(removed_map), None): + items.append(f'{next_indent}{RED}{self._format_value(removed_map.pop(index))}{RESET}') + index += 1 + items.append(f'{next_indent}{self._colorize_json(value, new_path, indent + 1)}') + index += 1 + for value in removed_map.values(): + items.append(f'{next_indent}{RED}{self._format_value(value)}{RESET}') + return '[\n' + ',\n'.join(items) + f'\n{current_indent}' + ']' + else: + return self._format_value(obj) + + def __str__(self) -> str: + """Return the colorized, pretty-printed JSON string.""" + self._colorize_skip_paths = set() + return self._colorize_json(self.t2, indent=0) + + def __iter__(self): + """Make the view iterable by yielding the tree results.""" + yield from self.tree.items() diff --git a/deepdiff/commands.py b/deepdiff/commands.py index 1859e35a..67aa94d8 100644 --- a/deepdiff/commands.py +++ b/deepdiff/commands.py @@ -55,6 +55,7 @@ def cli(): @click.option('--truncate-datetime', required=False, type=click.Choice(['second', 'minute', 'hour', 'day'], case_sensitive=True), show_default=True, default=None) @click.option('--verbose-level', required=False, default=1, type=click.IntRange(0, 2), show_default=True) @click.option('--debug', is_flag=True, show_default=False) +@click.option('--view', required=False, type=click.Choice(['tree', 'colored'], case_sensitive=True), show_default=True, default="text") def diff( *args, **kwargs ): @@ -112,7 +113,10 @@ def diff( sys.stdout.buffer.write(delta.dumps()) else: try: - print(diff.to_json(indent=2)) + if kwargs["view"] == 'colored': + print(diff) + else: + print(diff.to_json(indent=2)) except Exception: pprint(diff, indent=2) diff --git a/deepdiff/diff.py b/deepdiff/diff.py index d2664ef6..50132f37 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -25,7 +25,7 @@ type_is_subclass_of_type_group, type_in_type_group, get_doc, number_to_string, datetime_normalize, KEY_TO_VAL_STR, booleans, np_ndarray, np_floating, get_numpy_ndarray_rows, RepeatedTimer, - TEXT_VIEW, TREE_VIEW, DELTA_VIEW, detailed__dict__, add_root_to_paths, + TEXT_VIEW, TREE_VIEW, DELTA_VIEW, COLORED_VIEW, detailed__dict__, add_root_to_paths, np, get_truncate_datetime, dict_, CannotCompare, ENUM_INCLUDE_KEYS, PydanticBaseModel, Opcode, SetOrdered, ipranges) from deepdiff.serialization import SerializationMixin @@ -40,6 +40,7 @@ from deepdiff.deephash import DeepHash, combine_hashes_lists from deepdiff.base import Base from deepdiff.lfucache import LFUCache, DummyLFU +from deepdiff.colored_view import ColoredView if TYPE_CHECKING: from pytz.tzinfo import BaseTzInfo @@ -365,7 +366,10 @@ def _group_by_sort_key(x): self.tree.remove_empty_keys() view_results = self._get_view_results(self.view) - self.update(view_results) + if self.view == COLORED_VIEW: + self._colored_view = view_results + else: + self.update(view_results) finally: if self.is_root: if cache_purge_level: @@ -1759,6 +1763,8 @@ def _get_view_results(self, view): result.remove_empty_keys() elif view == DELTA_VIEW: result = self._to_delta_dict(report_repetition_required=False) + elif view == COLORED_VIEW: + result = ColoredView(self.t2, tree_results=self.tree, verbose_level=self.verbose_level) else: raise ValueError(INVALID_VIEW_MSG.format(view)) return result @@ -1899,6 +1905,11 @@ def affected_root_keys(self): result.add(root_key) return result + def __str__(self): + if hasattr(self, '_colored_view') and self.view == COLORED_VIEW: + return str(self._colored_view) + return super().__str__() + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/deepdiff/helper.py b/deepdiff/helper.py index f06b315c..150bbf22 100644 --- a/deepdiff/helper.py +++ b/deepdiff/helper.py @@ -209,6 +209,7 @@ class IndexedHash(NamedTuple): TREE_VIEW = 'tree' TEXT_VIEW = 'text' DELTA_VIEW = '_delta' +COLORED_VIEW = 'colored' ENUM_INCLUDE_KEYS = ['__objclass__', 'name', 'value'] diff --git a/docs/colored_view.rst b/docs/colored_view.rst new file mode 100644 index 00000000..06b5041d --- /dev/null +++ b/docs/colored_view.rst @@ -0,0 +1,60 @@ +.. _colored_view_label: + +Colored View +============ + +The `ColoredView` feature in `deepdiff` provides a human-readable, color-coded JSON output of the +differences between two objects. This feature is particularly useful for visualizing changes in a +clear and intuitive manner. + +- **Color-Coded Differences:** + + - **Added Elements:** Shown in green. + - **Removed Elements:** Shown in red. + - **Changed Elements:** The old value is shown in red, and the new value is shown in green. + +Usage +----- + +To use the `ColoredView`, simply pass the `COLORED_VIEW` option to the `DeepDiff` function: + +.. code-block:: python + + from deepdiff import DeepDiff + from deepdiff.helper import COLORED_VIEW + + t1 = {"name": "John", "age": 30, "scores": [1, 2, 3]} + t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "new": "value"} + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + print(diff) + +Or from command line: + +.. code-block:: bash + + deep diff --view colored t1.json t2.json + +Example Output +-------------- + +The output will look something like this: + +.. raw:: html + +
+ { + "name": "John", + "age": 30 -> 31, + "scores": [ + 1, + 2, + 3 -> 4 + ], + "address": { + "city": "New York" -> "Boston", + "zip": "10001" + }, + "new": "value" + } +diff --git a/docs/index.rst b/docs/index.rst index c49c0fff..9b0e68d7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -132,6 +132,7 @@ References deephash delta extract + colored_view commandline changelog authors diff --git a/tests/test_colored_view.py b/tests/test_colored_view.py new file mode 100644 index 00000000..08487339 --- /dev/null +++ b/tests/test_colored_view.py @@ -0,0 +1,140 @@ +from deepdiff import DeepDiff +from deepdiff.helper import COLORED_VIEW +from deepdiff.colored_view import RED, GREEN, RESET + +def test_colored_view_basic(): + t1 = { + "name": "John", + "age": 30, + "gender": "male", + "scores": [1, 2, 3], + "address": { + "city": "New York", + "zip": "10001", + }, + } + + t2 = { + "name": "John", + "age": 31, # Changed + "scores": [1, 2, 4], # Changed + "address": { + "city": "Boston", # Changed + "zip": "10001", + }, + "team": "abc", # Added + } + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + result = str(diff) + + expected = f'''{{ + "name": "John", + "age": {RED}30{RESET} -> {GREEN}31{RESET}, + "scores": [ + 1, + 2, + {RED}3{RESET} -> {GREEN}4{RESET} + ], + "address": {{ + "city": {RED}"New York"{RESET} -> {GREEN}"Boston"{RESET}, + "zip": "10001" + }}, + {GREEN}"team": {GREEN}"abc"{RESET}{RESET}, + {RED}"gender": {RED}"male"{RESET}{RESET} +}}''' + assert result == expected + +def test_colored_view_nested_changes(): + t1 = { + "level1": { + "level2": { + "level3": { + "level4": True + } + } + } + } + + t2 = { + "level1": { + "level2": { + "level3": { + "level4": False + } + } + } + } + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + result = str(diff) + + expected = f'''{{ + "level1": {{ + "level2": {{ + "level3": {{ + "level4": {RED}true{RESET} -> {GREEN}false{RESET} + }} + }} + }} +}}''' + assert result == expected + +def test_colored_view_list_changes(): + t1 = [1, 2, 3, 4] + t2 = [1, 5, 3, 6] + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + result = str(diff) + + expected = f'''[ + 1, + {RED}2{RESET} -> {GREEN}5{RESET}, + 3, + {RED}4{RESET} -> {GREEN}6{RESET} +]''' + assert result == expected + +def test_colored_view_list_deletions(): + t1 = [1, 2, 3, 4, 5, 6] + t2 = [2, 4] + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + result = str(diff) + + expected = f'''[ + {RED}1{RESET}, + 2, + {RED}3{RESET}, + 4, + {RED}5{RESET}, + {RED}6{RESET} +]''' + assert result == expected + +def test_colored_view_with_ignore_order(): + t1 = [1, 2, 3] + t2 = [3, 2, 1] + + diff = DeepDiff(t1, t2, view=COLORED_VIEW, ignore_order=True) + result = str(diff) + + expected = '''[ + 3, + 2, + 1 +]''' + assert result == expected + +def test_colored_view_with_empty_diff(): + t1 = {"a": 1, "b": 2} + t2 = {"a": 1, "b": 2} + + diff = DeepDiff(t1, t2, view=COLORED_VIEW) + result = str(diff) + + expected = '''{ + "a": 1, + "b": 2 +}''' + assert result == expected From eca5dfe9e8442bfeda8805a486e484b672ede2a7 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 19 May 2025 17:10:13 +0200 Subject: [PATCH 2/4] Colored compact view --- deepdiff/colored_view.py | 12 ++- deepdiff/commands.py | 4 +- deepdiff/diff.py | 12 +-- deepdiff/helper.py | 1 + docs/colored_view.rst | 51 +++++++++-- docs/commandline.rst | 9 ++ docs/view.rst | 1 + tests/test_colored_view.py | 173 ++++++++++++++++++++++++++++++++++++- 8 files changed, 249 insertions(+), 14 deletions(-) diff --git a/deepdiff/colored_view.py b/deepdiff/colored_view.py index c946f008..7513df1c 100644 --- a/deepdiff/colored_view.py +++ b/deepdiff/colored_view.py @@ -17,10 +17,11 @@ class ColoredView: """A view that shows JSON with color-coded differences.""" - def __init__(self, t2, tree_results, verbose_level=1): + def __init__(self, t2, tree_results, verbose_level=1, compact=False): self.t2 = t2 self.tree = tree_results self.verbose_level = verbose_level + self.compact = compact self.diff_paths = self._collect_diff_paths() def _collect_diff_paths(self) -> Dict[str, str]: @@ -63,11 +64,16 @@ def _get_path_removed(self, path: str) -> dict: removed[literal_eval(key_suffix[1:-1])] = value[1] return removed + def _has_differences(self, path_prefix: str) -> bool: + """Check if a path prefix has any differences under it.""" + return any(diff_path.startswith(path_prefix + "[") for diff_path in self.diff_paths) + def _colorize_json(self, obj: Any, path: str = 'root', indent: int = 0) -> str: """Recursively colorize JSON based on differences, with pretty-printing.""" INDENT = ' ' current_indent = INDENT * indent next_indent = INDENT * (indent + 1) + if path in self.diff_paths and path not in self._colorize_skip_paths: diff_type, old, new = self.diff_paths[path] if diff_type == 'changed': @@ -77,6 +83,9 @@ def _colorize_json(self, obj: Any, path: str = 'root', indent: int = 0) -> str: elif diff_type == 'removed': return f"{RED}{self._format_value(old)}{RESET}" + if isinstance(obj, (dict, list)) and self.compact and not self._has_differences(path): + return '{...}' if isinstance(obj, dict) else '[...]' + if isinstance(obj, dict): if not obj: return '{}' @@ -99,6 +108,7 @@ def _colorize_json(self, obj: Any, path: str = 'root', indent: int = 0) -> str: removed_map = self._get_path_removed(path) for index in removed_map: self._colorize_skip_paths.add(f"{path}[{index}]") + items = [] index = 0 for value in obj: diff --git a/deepdiff/commands.py b/deepdiff/commands.py index 67aa94d8..359a6efa 100644 --- a/deepdiff/commands.py +++ b/deepdiff/commands.py @@ -54,8 +54,8 @@ def cli(): @click.option('--significant-digits', required=False, default=None, type=int, show_default=True) @click.option('--truncate-datetime', required=False, type=click.Choice(['second', 'minute', 'hour', 'day'], case_sensitive=True), show_default=True, default=None) @click.option('--verbose-level', required=False, default=1, type=click.IntRange(0, 2), show_default=True) +@click.option('--view', required=False, type=click.Choice(['-', 'colored', 'colored_compact'], case_sensitive=True), show_default=True, default="-") @click.option('--debug', is_flag=True, show_default=False) -@click.option('--view', required=False, type=click.Choice(['tree', 'colored'], case_sensitive=True), show_default=True, default="text") def diff( *args, **kwargs ): @@ -113,7 +113,7 @@ def diff( sys.stdout.buffer.write(delta.dumps()) else: try: - if kwargs["view"] == 'colored': + if kwargs["view"] in {'colored', 'colored_compact'}: print(diff) else: print(diff.to_json(indent=2)) diff --git a/deepdiff/diff.py b/deepdiff/diff.py index 50132f37..2135042f 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -25,7 +25,7 @@ type_is_subclass_of_type_group, type_in_type_group, get_doc, number_to_string, datetime_normalize, KEY_TO_VAL_STR, booleans, np_ndarray, np_floating, get_numpy_ndarray_rows, RepeatedTimer, - TEXT_VIEW, TREE_VIEW, DELTA_VIEW, COLORED_VIEW, detailed__dict__, add_root_to_paths, + TEXT_VIEW, TREE_VIEW, DELTA_VIEW, COLORED_VIEW, COLORED_COMPACT_VIEW, __dict__, add_root_to_paths, np, get_truncate_datetime, dict_, CannotCompare, ENUM_INCLUDE_KEYS, PydanticBaseModel, Opcode, SetOrdered, ipranges) from deepdiff.serialization import SerializationMixin @@ -81,7 +81,7 @@ def _report_progress(_stats, progress_logger, duration): PREVIOUS_DIFF_COUNT = 'PREVIOUS DIFF COUNT' PREVIOUS_DISTANCE_CACHE_HIT_COUNT = 'PREVIOUS DISTANCE CACHE HIT COUNT' CANT_FIND_NUMPY_MSG = 'Unable to import numpy. This must be a bug in DeepDiff since a numpy array is detected.' -INVALID_VIEW_MSG = 'The only valid values for the view parameter are text and tree. But {} was passed.' +INVALID_VIEW_MSG = "view parameter must be one of 'text', 'tree', 'delta', 'colored' or 'colored_compact'. But {} was passed." CUTOFF_RANGE_ERROR_MSG = 'cutoff_distance_for_pairs needs to be a positive float max 1.' VERBOSE_LEVEL_RANGE_MSG = 'verbose_level should be 0, 1, or 2.' PURGE_LEVEL_RANGE_MSG = 'cache_purge_level should be 0, 1, or 2.' @@ -366,7 +366,7 @@ def _group_by_sort_key(x): self.tree.remove_empty_keys() view_results = self._get_view_results(self.view) - if self.view == COLORED_VIEW: + if self.view in {COLORED_VIEW, COLORED_COMPACT_VIEW}: self._colored_view = view_results else: self.update(view_results) @@ -1764,7 +1764,9 @@ def _get_view_results(self, view): elif view == DELTA_VIEW: result = self._to_delta_dict(report_repetition_required=False) elif view == COLORED_VIEW: - result = ColoredView(self.t2, tree_results=self.tree, verbose_level=self.verbose_level) + result = ColoredView(self.t2, tree_results=result, verbose_level=self.verbose_level) + elif view == COLORED_COMPACT_VIEW: + result = ColoredView(self.t2, tree_results=result, verbose_level=self.verbose_level, compact=True) else: raise ValueError(INVALID_VIEW_MSG.format(view)) return result @@ -1906,7 +1908,7 @@ def affected_root_keys(self): return result def __str__(self): - if hasattr(self, '_colored_view') and self.view == COLORED_VIEW: + if hasattr(self, '_colored_view') and self.view in {COLORED_VIEW, COLORED_COMPACT_VIEW}: return str(self._colored_view) return super().__str__() diff --git a/deepdiff/helper.py b/deepdiff/helper.py index 150bbf22..32a2ab19 100644 --- a/deepdiff/helper.py +++ b/deepdiff/helper.py @@ -210,6 +210,7 @@ class IndexedHash(NamedTuple): TEXT_VIEW = 'text' DELTA_VIEW = '_delta' COLORED_VIEW = 'colored' +COLORED_COMPACT_VIEW = 'colored_compact' ENUM_INCLUDE_KEYS = ['__objclass__', 'name', 'value'] diff --git a/docs/colored_view.rst b/docs/colored_view.rst index 06b5041d..16f49ab7 100644 --- a/docs/colored_view.rst +++ b/docs/colored_view.rst @@ -23,8 +23,8 @@ To use the `ColoredView`, simply pass the `COLORED_VIEW` option to the `DeepDiff from deepdiff import DeepDiff from deepdiff.helper import COLORED_VIEW - t1 = {"name": "John", "age": 30, "scores": [1, 2, 3]} - t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "new": "value"} + t1 = {"name": "John", "age": 30, "scores": [1, 2, 3], "address": {"city": "New York", "zip": "10001"}} + t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "address": {"city": "Boston", "zip": "10001"}, "new": "value"} diff = DeepDiff(t1, t2, view=COLORED_VIEW) print(diff) @@ -35,9 +35,6 @@ Or from command line: deep diff --view colored t1.json t2.json -Example Output --------------- - The output will look something like this: .. raw:: html @@ -58,3 +55,47 @@ The output will look something like this: "new": "value" } + +Colored Compact View +-------------------- + +For a more concise output, especially with deeply nested objects where many parts are unchanged, +the `ColoredView` with the compact option can be used. This view is similar but collapses +unchanged nested dictionaries to `{...}` and unchanged lists/tuples to `[...]`. To use the compact +option do: + +.. code-block:: python + + from deepdiff import DeepDiff + from deepdiff.helper import COLORED_COMPACT_VIEW + + t1 = {"name": "John", "age": 30, "scores": [1, 2, 3], "address": {"city": "New York", "zip": "10001"}} + t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "address": {"city": "New York", "zip": "10001"}, "new": "value"} + + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + print(diff) + +Or from command line: + +.. code-block:: bash + + deep diff --view colored_compact t1.json t2.json + + +The output will look something like this: + +.. raw:: html + +
+ { + "name": "John", + "age": 30 -> 31, + "scores": [ + 1, + 2, + 3 -> 4 + ], + "address": {...}, + "new": "value" + } +diff --git a/docs/commandline.rst b/docs/commandline.rst index 86652443..94b6aa73 100644 --- a/docs/commandline.rst +++ b/docs/commandline.rst @@ -72,6 +72,9 @@ to get the options: --significant-digits INTEGER --truncate-datetime [second|minute|hour|day] --verbose-level INTEGER RANGE [default: 1] + --view [-|colored|colored_compact] + [default: -] + Format for displaying differences. --help Show this message and exit. @@ -109,6 +112,12 @@ The path is perhaps more readable now: `root['Molotov']['zip']`. It is more clea .. Note:: The parameters in the deep diff commandline are a subset of those in :ref:`deepdiff_module_label` 's Python API. +To output in a specific format, for example the colored compact view (see :doc:`colored_view` for output details): + +.. code-block:: bash + + $ deep diff t1.json t2.json --view colored_compact + .. _deep_grep_command: diff --git a/docs/view.rst b/docs/view.rst index 6343590f..743022ad 100644 --- a/docs/view.rst +++ b/docs/view.rst @@ -9,6 +9,7 @@ You have the options of text view and tree view. The main difference is that the tree view has the capabilities to traverse the objects to see what objects were compared to what other objects. While the view options decide the format of the output that is mostly machine readable, regardless of the view you choose, you can get a more human readable output by using the pretty() method. +DeepDiff also offers other specialized views such as the :doc:`colored_view` (which includes a compact variant) and :doc:`delta` view for specific use cases. .. _text_view_label: diff --git a/tests/test_colored_view.py b/tests/test_colored_view.py index 08487339..883b14cc 100644 --- a/tests/test_colored_view.py +++ b/tests/test_colored_view.py @@ -1,5 +1,5 @@ from deepdiff import DeepDiff -from deepdiff.helper import COLORED_VIEW +from deepdiff.helper import COLORED_VIEW, COLORED_COMPACT_VIEW from deepdiff.colored_view import RED, GREEN, RESET def test_colored_view_basic(): @@ -138,3 +138,174 @@ def test_colored_view_with_empty_diff(): "b": 2 }''' assert result == expected + +def test_compact_view_basic(): + t1 = { + "name": "John", + "age": 30, + "gender": "male", + "scores": [1, 2, 3], + "address": { + "city": "New York", + "zip": "10001", + "details": { + "type": "apartment", + "floor": 5 + } + }, + "hobbies": ["reading", {"sport": "tennis", "level": "advanced"}] + } + + t2 = { + "name": "John", + "age": 31, # Changed + "scores": [1, 2, 4], # Changed + "address": { + "city": "Boston", # Changed + "zip": "10001", + "details": { + "type": "apartment", + "floor": 5 + } + }, + "team": "abc", # Added + "hobbies": ["reading", {"sport": "tennis", "level": "advanced"}] + } + + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + result = str(diff) + + expected = f'''{{ + "name": "John", + "age": {RED}30{RESET} -> {GREEN}31{RESET}, + "scores": [ + 1, + 2, + {RED}3{RESET} -> {GREEN}4{RESET} + ], + "address": {{ + "city": {RED}"New York"{RESET} -> {GREEN}"Boston"{RESET}, + "zip": "10001", + "details": {{...}} + }}, + {GREEN}"team": {GREEN}"abc"{RESET}{RESET}, + "hobbies": [...], + {RED}"gender": {RED}"male"{RESET}{RESET} +}}''' + assert result == expected + +def test_compact_view_nested_changes(): + t1 = { + "level1": { + "unchanged1": { + "deep1": True, + "deep2": [1, 2, 3] + }, + "level2": { + "a": 1, + "b": "test", + "c": [1, 2, 3], + "d": {"x": 1, "y": 2} + }, + "unchanged2": [1, 2, {"a": 1}] + } + } + + t2 = { + "level1": { + "unchanged1": { + "deep1": True, + "deep2": [1, 2, 3] + }, + "level2": { + "a": 2, # Changed + "b": "test", + "c": [1, 2, 4], # Changed + "d": {"x": 1, "y": 3} # Changed + }, + "unchanged2": [1, 2, {"a": 1}] + } + } + + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + result = str(diff) + + expected = f'''{{ + "level1": {{ + "unchanged1": {{...}}, + "level2": {{ + "a": {RED}1{RESET} -> {GREEN}2{RESET}, + "b": "test", + "c": [ + 1, + 2, + {RED}3{RESET} -> {GREEN}4{RESET} + ], + "d": {{ + "x": 1, + "y": {RED}2{RESET} -> {GREEN}3{RESET} + }} + }}, + "unchanged2": [...] + }} +}}''' + assert result == expected + +def test_compact_view_no_changes(): + # Test with dict + t1 = {"a": 1, "b": [1, 2], "c": {"x": True}} + t2 = {"a": 1, "b": [1, 2], "c": {"x": True}} + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + assert str(diff) == "{...}" + + # Test with list + t1 = [1, {"a": 1}, [1, 2]] + t2 = [1, {"a": 1}, [1, 2]] + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + assert str(diff) == "[...]" + +def test_compact_view_list_changes(): + t1 = [1, {"a": 1, "b": {"x": 1, "y": 2}}, [1, 2, {"z": 3}]] + t2 = [1, {"a": 2, "b": {"x": 1, "y": 2}}, [1, 2, {"z": 3}]] + + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + result = str(diff) + + expected = f'''[ + 1, + {{ + "a": {RED}1{RESET} -> {GREEN}2{RESET}, + "b": {{...}} + }}, + [...] +]''' + assert result == expected + +def test_compact_view_primitive_siblings(): + t1 = { + "changed": 1, + "str_sibling": "hello", + "int_sibling": 42, + "bool_sibling": True, + "nested_sibling": {"a": 1, "b": 2} + } + + t2 = { + "changed": 2, + "str_sibling": "hello", + "int_sibling": 42, + "bool_sibling": True, + "nested_sibling": {"a": 1, "b": 2} + } + + diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW) + result = str(diff) + + expected = f'''{{ + "changed": {RED}1{RESET} -> {GREEN}2{RESET}, + "str_sibling": "hello", + "int_sibling": 42, + "bool_sibling": true, + "nested_sibling": {{...}} +}}''' + assert result == expected From 77fa8d3440f7c70f79fb546dc7ca806757622f61 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Tue, 20 May 2025 15:39:34 +0200 Subject: [PATCH 3/4] Fix CLI --- deepdiff/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deepdiff/commands.py b/deepdiff/commands.py index 359a6efa..50b0d4d2 100644 --- a/deepdiff/commands.py +++ b/deepdiff/commands.py @@ -54,7 +54,7 @@ def cli(): @click.option('--significant-digits', required=False, default=None, type=int, show_default=True) @click.option('--truncate-datetime', required=False, type=click.Choice(['second', 'minute', 'hour', 'day'], case_sensitive=True), show_default=True, default=None) @click.option('--verbose-level', required=False, default=1, type=click.IntRange(0, 2), show_default=True) -@click.option('--view', required=False, type=click.Choice(['-', 'colored', 'colored_compact'], case_sensitive=True), show_default=True, default="-") +@click.option('--view', required=False, type=click.Choice(['tree', 'colored', 'colored_compact'], case_sensitive=True), show_default=True, default="tree") @click.option('--debug', is_flag=True, show_default=False) def diff( *args, **kwargs From 18a0334d24f55943edc0e8362352793b85654b8a Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Tue, 20 May 2025 16:21:41 +0200 Subject: [PATCH 4/4] Fix bool evaluation --- deepdiff/diff.py | 5 +++++ tests/test_colored_view.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/deepdiff/diff.py b/deepdiff/diff.py index 2135042f..8245221b 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -1907,6 +1907,11 @@ def affected_root_keys(self): result.add(root_key) return result + def __bool__(self): + if hasattr(self, '_colored_view') and self.view in {COLORED_VIEW, COLORED_COMPACT_VIEW}: + return bool(self.tree) # Use the tree for boolean evaluation, not the view + return super().__bool__() + def __str__(self): if hasattr(self, '_colored_view') and self.view in {COLORED_VIEW, COLORED_COMPACT_VIEW}: return str(self._colored_view) diff --git a/tests/test_colored_view.py b/tests/test_colored_view.py index 883b14cc..171b59b6 100644 --- a/tests/test_colored_view.py +++ b/tests/test_colored_view.py @@ -309,3 +309,28 @@ def test_compact_view_primitive_siblings(): "nested_sibling": {{...}} }}''' assert result == expected + + +def test_colored_view_bool_evaluation(): + # Test COLORED_VIEW + # Scenario 1: No differences + t1_no_diff = {"a": 1, "b": 2} + t2_no_diff = {"a": 1, "b": 2} + diff_no_diff_colored = DeepDiff(t1_no_diff, t2_no_diff, view=COLORED_VIEW) + assert not bool(diff_no_diff_colored), "bool(diff) should be False when no diffs (colored view)" + + # Scenario 2: With differences + t1_with_diff = {"a": 1, "b": 2} + t2_with_diff = {"a": 1, "b": 3} + diff_with_diff_colored = DeepDiff(t1_with_diff, t2_with_diff, view=COLORED_VIEW) + assert bool(diff_with_diff_colored), "bool(diff) should be True when diffs exist (colored view)" + + # Test COLORED_COMPACT_VIEW + # Scenario 1: No differences + diff_no_diff_compact = DeepDiff(t1_no_diff, t2_no_diff, view=COLORED_COMPACT_VIEW) + assert not bool(diff_no_diff_compact), "bool(diff) should be False when no diffs (compact view)" + + # Scenario 2: With differences + diff_with_diff_compact = DeepDiff(t1_with_diff, t2_with_diff, view=COLORED_COMPACT_VIEW) + assert bool(diff_with_diff_compact), "bool(diff) should be True when diffs exist (compact view)" +