Skip to content

Human readable colored view #549

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
134 changes: 134 additions & 0 deletions deepdiff/colored_view.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 5 additions & 1 deletion deepdiff/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(['-', 'colored', 'colored_compact'], case_sensitive=True), show_default=True, default="-")
@click.option('--debug', is_flag=True, show_default=False)
def diff(
*args, **kwargs
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 16 additions & 3 deletions deepdiff/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.'
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1899,6 +1907,11 @@ def affected_root_keys(self):
result.add(root_key)
return result

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
Expand Down
2 changes: 2 additions & 0 deletions deepdiff/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
101 changes: 101 additions & 0 deletions docs/colored_view.rst
Original file line number Diff line number Diff line change
@@ -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

<pre style="background-color: #f8f8f8; padding: 1em; border-radius: 4px;">
{
"name": "John",
"age": <span style="color: #ff0000">30</span> -> <span style="color: #00aa00">31</span>,
"scores": [
1,
2,
<span style="color: #ff0000">3</span> -> <span style="color: #00aa00">4</span>
],
"address": {
"city": <span style="color: #ff0000">"New York"</span> -> <span style="color: #00aa00">"Boston"</span>,
"zip": "10001"
},
<span style="color: #00aa00">"new": "value"</span>
}
</pre>

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

<pre style="background-color: #f8f8f8; padding: 1em; border-radius: 4px;">
{
"name": "John",
"age": <span style="color: #ff0000">30</span> -> <span style="color: #00aa00">31</span>,
"scores": [
1,
2,
<span style="color: #ff0000">3</span> -> <span style="color: #00aa00">4</span>
],
"address": {...},
<span style="color: #00aa00">"new": "value"</span>
}
</pre>
9 changes: 9 additions & 0 deletions docs/commandline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ References
deephash
delta
extract
colored_view
commandline
changelog
authors
Expand Down
1 change: 1 addition & 0 deletions docs/view.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading