diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd2a12..3c6b532 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 0000000..7513df1 --- /dev/null +++ b/deepdiff/colored_view.py @@ -0,0 +1,134 @@ +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, 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]: + """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 _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': + 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, list)) and self.compact and not self._has_differences(path): + return '{...}' if isinstance(obj, dict) else '[...]' + + 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 1859e35..50b0d4d 100644 --- a/deepdiff/commands.py +++ b/deepdiff/commands.py @@ -54,6 +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(['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 @@ -112,7 +113,10 @@ def diff( sys.stdout.buffer.write(delta.dumps()) else: try: - print(diff.to_json(indent=2)) + if kwargs["view"] in {'colored', 'colored_compact'}: + 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 d2664ef..8245221 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, 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 @@ -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 @@ -80,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.' @@ -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 in {COLORED_VIEW, COLORED_COMPACT_VIEW}: + self._colored_view = view_results + else: + self.update(view_results) finally: if self.is_root: if cache_purge_level: @@ -1759,6 +1763,10 @@ 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=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 @@ -1899,6 +1907,16 @@ 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) + return super().__str__() + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/deepdiff/helper.py b/deepdiff/helper.py index f06b315..32a2ab1 100644 --- a/deepdiff/helper.py +++ b/deepdiff/helper.py @@ -209,6 +209,8 @@ class IndexedHash(NamedTuple): TREE_VIEW = 'tree' 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 new file mode 100644 index 0000000..16f49ab --- /dev/null +++ b/docs/colored_view.rst @@ -0,0 +1,101 @@ +.. _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], "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) + +Or from command line: + +.. code-block:: bash + + deep diff --view colored t1.json t2.json + +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" + } ++ +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 8665244..94b6aa7 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/index.rst b/docs/index.rst index c49c0ff..9b0e68d 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/docs/view.rst b/docs/view.rst index 6343590..743022a 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 new file mode 100644 index 0000000..171b59b --- /dev/null +++ b/tests/test_colored_view.py @@ -0,0 +1,336 @@ +from deepdiff import DeepDiff +from deepdiff.helper import COLORED_VIEW, COLORED_COMPACT_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 + +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 + + +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)" +