Skip to content

Commit b25f726

Browse files
committed
Colored view
1 parent ecc823a commit b25f726

File tree

8 files changed

+347
-3
lines changed

8 files changed

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

deepdiff/commands.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def cli():
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)
5757
@click.option('--debug', is_flag=True, show_default=False)
58+
@click.option('--view', required=False, type=click.Choice(['tree', 'colored'], case_sensitive=True), show_default=True, default="text")
5859
def diff(
5960
*args, **kwargs
6061
):
@@ -112,7 +113,10 @@ def diff(
112113
sys.stdout.buffer.write(delta.dumps())
113114
else:
114115
try:
115-
print(diff.to_json(indent=2))
116+
if kwargs["view"] == 'colored':
117+
print(diff)
118+
else:
119+
print(diff.to_json(indent=2))
116120
except Exception:
117121
pprint(diff, indent=2)
118122

deepdiff/diff.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
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, detailed__dict__, add_root_to_paths,
2929
np, get_truncate_datetime, dict_, CannotCompare, ENUM_INCLUDE_KEYS,
3030
PydanticBaseModel, Opcode, SetOrdered, ipranges)
3131
from deepdiff.serialization import SerializationMixin
@@ -40,6 +40,7 @@
4040
from deepdiff.deephash import DeepHash, combine_hashes_lists
4141
from deepdiff.base import Base
4242
from deepdiff.lfucache import LFUCache, DummyLFU
43+
from deepdiff.colored_view import ColoredView
4344

4445
if TYPE_CHECKING:
4546
from pytz.tzinfo import BaseTzInfo
@@ -365,7 +366,10 @@ def _group_by_sort_key(x):
365366

366367
self.tree.remove_empty_keys()
367368
view_results = self._get_view_results(self.view)
368-
self.update(view_results)
369+
if self.view == COLORED_VIEW:
370+
self._colored_view = view_results
371+
else:
372+
self.update(view_results)
369373
finally:
370374
if self.is_root:
371375
if cache_purge_level:
@@ -1759,6 +1763,8 @@ def _get_view_results(self, view):
17591763
result.remove_empty_keys()
17601764
elif view == DELTA_VIEW:
17611765
result = self._to_delta_dict(report_repetition_required=False)
1766+
elif view == COLORED_VIEW:
1767+
result = ColoredView(self.t2, tree_results=self.tree, verbose_level=self.verbose_level)
17621768
else:
17631769
raise ValueError(INVALID_VIEW_MSG.format(view))
17641770
return result
@@ -1899,6 +1905,11 @@ def affected_root_keys(self):
18991905
result.add(root_key)
19001906
return result
19011907

1908+
def __str__(self):
1909+
if hasattr(self, '_colored_view') and self.view == COLORED_VIEW:
1910+
return str(self._colored_view)
1911+
return super().__str__()
1912+
19021913

19031914
if __name__ == "__main__": # pragma: no cover
19041915
import doctest

deepdiff/helper.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ class IndexedHash(NamedTuple):
209209
TREE_VIEW = 'tree'
210210
TEXT_VIEW = 'text'
211211
DELTA_VIEW = '_delta'
212+
COLORED_VIEW = 'colored'
212213

213214
ENUM_INCLUDE_KEYS = ['__objclass__', 'name', 'value']
214215

docs/colored_view.rst

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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]}
27+
t2 = {"name": "John", "age": 31, "scores": [1, 2, 4], "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+
Example Output
39+
--------------
40+
41+
The output will look something like this:
42+
43+
.. raw:: html
44+
45+
<pre style="background-color: #f8f8f8; padding: 1em; border-radius: 4px;">
46+
{
47+
"name": "John",
48+
"age": <span style="color: #ff0000">30</span> -> <span style="color: #00aa00">31</span>,
49+
"scores": [
50+
1,
51+
2,
52+
<span style="color: #ff0000">3</span> -> <span style="color: #00aa00">4</span>
53+
],
54+
"address": {
55+
"city": <span style="color: #ff0000">"New York"</span> -> <span style="color: #00aa00">"Boston"</span>,
56+
"zip": "10001"
57+
},
58+
<span style="color: #00aa00">"new": "value"</span>
59+
}
60+
</pre>

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

0 commit comments

Comments
 (0)