Skip to content

Commit 1c30c5a

Browse files
authored
Merge pull request #549 from mauvilsa/colored-view
Human readable colored view
2 parents 07e7d0a + bce6343 commit 1c30c5a

File tree

10 files changed

+708
-4
lines changed

10 files changed

+708
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# DeepDiff Change log
22

3+
- [Unreleased]
4+
- 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).
5+
36
- v8-5-0
47
- Updating deprecated pydantic calls
58
- Switching to pyproject.toml

deepdiff/colored_view.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import json
2+
import os
3+
from ast import literal_eval
4+
from importlib.util import find_spec
5+
from typing import Any, Dict
6+
7+
from deepdiff.model import TextResult, TreeResult
8+
9+
10+
if os.name == "nt" and find_spec("colorama"):
11+
import colorama
12+
13+
colorama.init()
14+
15+
16+
# ANSI color codes
17+
RED = '\033[31m'
18+
GREEN = '\033[32m'
19+
RESET = '\033[0m'
20+
21+
22+
class ColoredView:
23+
"""A view that shows JSON with color-coded differences."""
24+
25+
def __init__(self, t2: Any, tree_result: TreeResult, compact: bool = False):
26+
self.t2 = t2
27+
self.tree = tree_result
28+
self.compact = compact
29+
self.diff_paths = self._collect_diff_paths()
30+
31+
def _collect_diff_paths(self) -> Dict[str, str]:
32+
"""Collect all paths that have differences and their types."""
33+
text_result = TextResult(tree_results=self.tree, verbose_level=2)
34+
diff_paths = {}
35+
for diff_type, items in text_result.items():
36+
if not items:
37+
continue
38+
try:
39+
iter(items)
40+
except TypeError:
41+
continue
42+
for path, item in items.items():
43+
if diff_type in ("values_changed", "type_changes"):
44+
changed_path = item.get("new_path") or path
45+
diff_paths[changed_path] = ("changed", item["old_value"], item["new_value"])
46+
elif diff_type in ("dictionary_item_added", "iterable_item_added", "set_item_added"):
47+
diff_paths[path] = ("added", None, item)
48+
elif diff_type in ("dictionary_item_removed", "iterable_item_removed", "set_item_removed"):
49+
diff_paths[path] = ("removed", item, None)
50+
return diff_paths
51+
52+
def _format_value(self, value: Any) -> str:
53+
"""Format a value for display."""
54+
if isinstance(value, bool):
55+
return 'true' if value else 'false'
56+
elif isinstance(value, str):
57+
return f'"{value}"'
58+
elif isinstance(value, (dict, list, tuple)):
59+
return json.dumps(value)
60+
else:
61+
return str(value)
62+
63+
def _get_path_removed(self, path: str) -> dict:
64+
"""Get all removed items for a given path."""
65+
removed = {}
66+
for key, value in self.diff_paths.items():
67+
if value[0] == 'removed' and key.startswith(path + "["):
68+
key_suffix = key[len(path):]
69+
if key_suffix.count("[") == 1 and key_suffix.endswith("]"):
70+
removed[literal_eval(key_suffix[1:-1])] = value[1]
71+
return removed
72+
73+
def _has_differences(self, path_prefix: str) -> bool:
74+
"""Check if a path prefix has any differences under it."""
75+
return any(diff_path.startswith(path_prefix + "[") for diff_path in self.diff_paths)
76+
77+
def _colorize_json(self, obj: Any, path: str = 'root', indent: int = 0) -> str:
78+
"""Recursively colorize JSON based on differences, with pretty-printing."""
79+
INDENT = ' '
80+
current_indent = INDENT * indent
81+
next_indent = INDENT * (indent + 1)
82+
83+
if path in self.diff_paths and path not in self._colorize_skip_paths:
84+
diff_type, old, new = self.diff_paths[path]
85+
if diff_type == 'changed':
86+
return f"{RED}{self._format_value(old)}{RESET} -> {GREEN}{self._format_value(new)}{RESET}"
87+
elif diff_type == 'added':
88+
return f"{GREEN}{self._format_value(new)}{RESET}"
89+
elif diff_type == 'removed':
90+
return f"{RED}{self._format_value(old)}{RESET}"
91+
92+
if isinstance(obj, (dict, list)) and self.compact and not self._has_differences(path):
93+
return '{...}' if isinstance(obj, dict) else '[...]'
94+
95+
if isinstance(obj, dict):
96+
if not obj:
97+
return '{}'
98+
items = []
99+
for key, value in obj.items():
100+
new_path = f"{path}['{key}']" if isinstance(key, str) else f"{path}[{key}]"
101+
if new_path in self.diff_paths and self.diff_paths[new_path][0] == 'added':
102+
# Colorize both key and value for added fields
103+
items.append(f'{next_indent}{GREEN}"{key}": {self._colorize_json(value, new_path, indent + 1)}{RESET}')
104+
else:
105+
items.append(f'{next_indent}"{key}": {self._colorize_json(value, new_path, indent + 1)}')
106+
for key, value in self._get_path_removed(path).items():
107+
new_path = f"{path}['{key}']" if isinstance(key, str) else f"{path}[{key}]"
108+
items.append(f'{next_indent}{RED}"{key}": {self._colorize_json(value, new_path, indent + 1)}{RESET}')
109+
return '{\n' + ',\n'.join(items) + f'\n{current_indent}' + '}'
110+
111+
elif isinstance(obj, (list, tuple)):
112+
if not obj:
113+
return '[]'
114+
removed_map = self._get_path_removed(path)
115+
for index in removed_map:
116+
self._colorize_skip_paths.add(f"{path}[{index}]")
117+
118+
items = []
119+
remove_index = 0
120+
for index, value in enumerate(obj):
121+
while remove_index == next(iter(removed_map), None):
122+
items.append(f'{next_indent}{RED}{self._format_value(removed_map.pop(remove_index))}{RESET}')
123+
remove_index += 1
124+
items.append(f'{next_indent}{self._colorize_json(value, f"{path}[{index}]", indent + 1)}')
125+
remove_index += 1
126+
for value in removed_map.values():
127+
items.append(f'{next_indent}{RED}{self._format_value(value)}{RESET}')
128+
return '[\n' + ',\n'.join(items) + f'\n{current_indent}' + ']'
129+
else:
130+
return self._format_value(obj)
131+
132+
def __str__(self) -> str:
133+
"""Return the colorized, pretty-printed JSON string."""
134+
self._colorize_skip_paths = set()
135+
return self._colorize_json(self.t2)
136+
137+
def __iter__(self):
138+
"""Make the view iterable by yielding the tree results."""
139+
yield from self.tree.items()

deepdiff/commands.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def cli():
5454
@click.option('--significant-digits', required=False, default=None, type=int, show_default=True)
5555
@click.option('--truncate-datetime', required=False, type=click.Choice(['second', 'minute', 'hour', 'day'], case_sensitive=True), show_default=True, default=None)
5656
@click.option('--verbose-level', required=False, default=1, type=click.IntRange(0, 2), show_default=True)
57+
@click.option('--view', required=False, type=click.Choice(['tree', 'colored', 'colored_compact'], case_sensitive=True), show_default=True, default='tree')
5758
@click.option('--debug', is_flag=True, show_default=False)
5859
def diff(
5960
*args, **kwargs
@@ -74,6 +75,8 @@ def diff(
7475
t2_path = kwargs.pop("t2")
7576
t1_extension = t1_path.split('.')[-1]
7677
t2_extension = t2_path.split('.')[-1]
78+
if "view" in kwargs and kwargs["view"] is None:
79+
kwargs.pop("view")
7780

7881
for name, t_path, t_extension in [('t1', t1_path, t1_extension), ('t2', t2_path, t2_extension)]:
7982
try:
@@ -112,7 +115,10 @@ def diff(
112115
sys.stdout.buffer.write(delta.dumps())
113116
else:
114117
try:
115-
print(diff.to_json(indent=2))
118+
if kwargs["view"] in {'colored', 'colored_compact'}:
119+
print(diff)
120+
else:
121+
print(diff.to_json(indent=2))
116122
except Exception:
117123
pprint(diff, indent=2)
118124

deepdiff/diff.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
type_is_subclass_of_type_group, type_in_type_group, get_doc,
2626
number_to_string, datetime_normalize, KEY_TO_VAL_STR, booleans,
2727
np_ndarray, np_floating, get_numpy_ndarray_rows, RepeatedTimer,
28-
TEXT_VIEW, TREE_VIEW, DELTA_VIEW, detailed__dict__, add_root_to_paths,
28+
TEXT_VIEW, TREE_VIEW, DELTA_VIEW, COLORED_VIEW, COLORED_COMPACT_VIEW,
29+
detailed__dict__, add_root_to_paths,
2930
np, get_truncate_datetime, dict_, CannotCompare, ENUM_INCLUDE_KEYS,
3031
PydanticBaseModel, Opcode, SetOrdered, ipranges)
3132
from deepdiff.serialization import SerializationMixin
@@ -40,6 +41,7 @@
4041
from deepdiff.deephash import DeepHash, combine_hashes_lists
4142
from deepdiff.base import Base
4243
from deepdiff.lfucache import LFUCache, DummyLFU
44+
from deepdiff.colored_view import ColoredView
4345

4446
if TYPE_CHECKING:
4547
from pytz.tzinfo import BaseTzInfo
@@ -80,7 +82,7 @@ def _report_progress(_stats, progress_logger, duration):
8082
PREVIOUS_DIFF_COUNT = 'PREVIOUS DIFF COUNT'
8183
PREVIOUS_DISTANCE_CACHE_HIT_COUNT = 'PREVIOUS DISTANCE CACHE HIT COUNT'
8284
CANT_FIND_NUMPY_MSG = 'Unable to import numpy. This must be a bug in DeepDiff since a numpy array is detected.'
83-
INVALID_VIEW_MSG = 'The only valid values for the view parameter are text and tree. But {} was passed.'
85+
INVALID_VIEW_MSG = "view parameter must be one of 'text', 'tree', 'delta', 'colored' or 'colored_compact'. But {} was passed."
8486
CUTOFF_RANGE_ERROR_MSG = 'cutoff_distance_for_pairs needs to be a positive float max 1.'
8587
VERBOSE_LEVEL_RANGE_MSG = 'verbose_level should be 0, 1, or 2.'
8688
PURGE_LEVEL_RANGE_MSG = 'cache_purge_level should be 0, 1, or 2.'
@@ -365,7 +367,11 @@ def _group_by_sort_key(x):
365367

366368
self.tree.remove_empty_keys()
367369
view_results = self._get_view_results(self.view)
368-
self.update(view_results)
370+
if isinstance(view_results, ColoredView):
371+
self.update(view_results.tree)
372+
self._colored_view = view_results
373+
else:
374+
self.update(view_results)
369375
finally:
370376
if self.is_root:
371377
if cache_purge_level:
@@ -1759,6 +1765,10 @@ def _get_view_results(self, view):
17591765
result.remove_empty_keys()
17601766
elif view == DELTA_VIEW:
17611767
result = self._to_delta_dict(report_repetition_required=False)
1768+
elif view == COLORED_VIEW:
1769+
result = ColoredView(t2=self.t2, tree_result=self.tree, compact=False)
1770+
elif view == COLORED_COMPACT_VIEW:
1771+
result = ColoredView(t2=self.t2, tree_result=self.tree, compact=True)
17621772
else:
17631773
raise ValueError(INVALID_VIEW_MSG.format(view))
17641774
return result
@@ -1899,6 +1909,11 @@ def affected_root_keys(self):
18991909
result.add(root_key)
19001910
return result
19011911

1912+
def __str__(self):
1913+
if hasattr(self, '_colored_view') and self.view in {COLORED_VIEW, COLORED_COMPACT_VIEW}:
1914+
return str(self._colored_view)
1915+
return super().__str__()
1916+
19021917

19031918
if __name__ == "__main__": # pragma: no cover
19041919
import doctest

deepdiff/helper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ class IndexedHash(NamedTuple):
209209
TREE_VIEW = 'tree'
210210
TEXT_VIEW = 'text'
211211
DELTA_VIEW = '_delta'
212+
COLORED_VIEW = 'colored'
213+
COLORED_COMPACT_VIEW = 'colored_compact'
212214

213215
ENUM_INCLUDE_KEYS = ['__objclass__', 'name', 'value']
214216

docs/colored_view.rst

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
.. _colored_view_label:
2+
3+
Colored View
4+
============
5+
6+
The `ColoredView` feature in `deepdiff` provides a human-readable, color-coded JSON output of the
7+
differences between two objects. This feature is particularly useful for visualizing changes in a
8+
clear and intuitive manner.
9+
10+
- **Color-Coded Differences:**
11+
12+
- **Added Elements:** Shown in green.
13+
- **Removed Elements:** Shown in red.
14+
- **Changed Elements:** The old value is shown in red, and the new value is shown in green.
15+
16+
Usage
17+
-----
18+
19+
To use the `ColoredView`, simply pass the `COLORED_VIEW` option to the `DeepDiff` function:
20+
21+
.. code-block:: python
22+
23+
from deepdiff import DeepDiff
24+
from deepdiff.helper import COLORED_VIEW
25+
26+
t1 = {"name": "John", "age": 30, "scores": [1, 2, 3], "address": {"city": "New York", "zip": "10001"}}
27+
t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "address": {"city": "Boston", "zip": "10001"}, "new": "value"}
28+
29+
diff = DeepDiff(t1, t2, view=COLORED_VIEW)
30+
print(diff)
31+
32+
Or from command line:
33+
34+
.. code-block:: bash
35+
36+
deep diff --view colored t1.json t2.json
37+
38+
The output will look something like this:
39+
40+
.. raw:: html
41+
42+
<pre style="background-color: #f8f8f8; padding: 1em; border-radius: 4px;">
43+
{
44+
"name": "John",
45+
"age": <span style="color: #ff0000">30</span> -> <span style="color: #00aa00">31</span>,
46+
"scores": [
47+
1,
48+
2,
49+
<span style="color: #ff0000">3</span> -> <span style="color: #00aa00">4</span>
50+
],
51+
"address": {
52+
"city": <span style="color: #ff0000">"New York"</span> -> <span style="color: #00aa00">"Boston"</span>,
53+
"zip": "10001"
54+
},
55+
<span style="color: #00aa00">"new": "value"</span>
56+
}
57+
</pre>
58+
59+
Colored Compact View
60+
--------------------
61+
62+
For a more concise output, especially with deeply nested objects where many parts are unchanged,
63+
the `ColoredView` with the compact option can be used. This view is similar but collapses
64+
unchanged nested dictionaries to `{...}` and unchanged lists/tuples to `[...]`. To use the compact
65+
option do:
66+
67+
.. code-block:: python
68+
69+
from deepdiff import DeepDiff
70+
from deepdiff.helper import COLORED_COMPACT_VIEW
71+
72+
t1 = {"name": "John", "age": 30, "scores": [1, 2, 3], "address": {"city": "New York", "zip": "10001"}}
73+
t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "address": {"city": "New York", "zip": "10001"}, "new": "value"}
74+
75+
diff = DeepDiff(t1, t2, view=COLORED_COMPACT_VIEW)
76+
print(diff)
77+
78+
Or from command line:
79+
80+
.. code-block:: bash
81+
82+
deep diff --view colored_compact t1.json t2.json
83+
84+
85+
The output will look something like this:
86+
87+
.. raw:: html
88+
89+
<pre style="background-color: #f8f8f8; padding: 1em; border-radius: 4px;">
90+
{
91+
"name": "John",
92+
"age": <span style="color: #ff0000">30</span> -> <span style="color: #00aa00">31</span>,
93+
"scores": [
94+
1,
95+
2,
96+
<span style="color: #ff0000">3</span> -> <span style="color: #00aa00">4</span>
97+
],
98+
"address": {...},
99+
<span style="color: #00aa00">"new": "value"</span>
100+
}
101+
</pre>

docs/commandline.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ to get the options:
7272
--significant-digits INTEGER
7373
--truncate-datetime [second|minute|hour|day]
7474
--verbose-level INTEGER RANGE [default: 1]
75+
--view [-|colored|colored_compact]
76+
[default: -]
77+
Format for displaying differences.
7578
--help Show this message and exit.
7679
7780
@@ -109,6 +112,12 @@ The path is perhaps more readable now: `root['Molotov']['zip']`. It is more clea
109112
.. Note::
110113
The parameters in the deep diff commandline are a subset of those in :ref:`deepdiff_module_label` 's Python API.
111114
115+
To output in a specific format, for example the colored compact view (see :doc:`colored_view` for output details):
116+
117+
.. code-block:: bash
118+
119+
$ deep diff t1.json t2.json --view colored_compact
120+
112121
113122
.. _deep_grep_command:
114123

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ References
132132
deephash
133133
delta
134134
extract
135+
colored_view
135136
commandline
136137
changelog
137138
authors

docs/view.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ You have the options of text view and tree view.
99
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.
1010

1111
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.
12+
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.
1213

1314
.. _text_view_label:
1415

0 commit comments

Comments
 (0)