Skip to content
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
1 change: 1 addition & 0 deletions deepdiff/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def cli():
@click.option('--exclude-paths', required=False, type=str, show_default=False, multiple=True)
@click.option('--exclude-regex-paths', required=False, type=str, show_default=False, multiple=True)
@click.option('--math-epsilon', required=False, type=Decimal, show_default=False)
@click.option('--math-rel-epsilon', required=False, type=Decimal, show_default=False)
@click.option('--get-deep-distance', is_flag=True, show_default=True)
@click.option('--group-by', required=False, type=str, show_default=False, multiple=False)
@click.option('--ignore-order', is_flag=True, show_default=True)
Expand Down
17 changes: 12 additions & 5 deletions deepdiff/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def __init__(self,
log_scale_similarity_threshold: float=0.1,
log_stacktrace: bool=False,
math_epsilon: Optional[float]=None,
math_rel_epsilon: Optional[float]=None,
max_diffs: Optional[int]=None,
max_passes: int=10000000,
multiprocessing: bool=False,
Expand Down Expand Up @@ -226,7 +227,7 @@ def __init__(self,
"view, hasher, hashes, max_passes, max_diffs, zip_ordered_iterables, "
"cutoff_distance_for_pairs, cutoff_intersection_for_pairs, log_frequency_in_sec, cache_size, "
"cache_tuning_sample_size, get_deep_distance, group_by, group_by_sort_key, cache_purge_level, log_stacktrace,"
"math_epsilon, iterable_compare_func, use_enum_value, _original_type, threshold_to_diff_deeper, default_timezone "
"math_epsilon, math_rel_epsilon, iterable_compare_func, use_enum_value, _original_type, threshold_to_diff_deeper, default_timezone "
"ignore_order_func, custom_operators, encodings, ignore_encoding_errors, use_log_scale, log_scale_similarity_threshold "
"multiprocessing, multiprocessing_workers, multiprocessing_threshold, "
"_parameters and _shared_parameters.") % ', '.join(kwargs.keys()))
Expand Down Expand Up @@ -301,8 +302,9 @@ def _group_by_sort_key(x):

self.significant_digits = self.get_significant_digits(significant_digits, ignore_numeric_type_changes)
self.math_epsilon = math_epsilon
if self.math_epsilon is not None and self.ignore_order:
logger.warning("math_epsilon in conjunction with ignore_order=True is only used for flat object comparisons. Custom math_epsilon will not have an effect when comparing nested objects.")
self.math_rel_epsilon = math_rel_epsilon
if (self.math_epsilon is not None or self.math_rel_epsilon is not None) and self.ignore_order:
logger.warning("math_epsilon and math_rel_epsilon in conjunction with ignore_order=True are only used for flat object comparisons. Custom math_epsilon and math_rel_epsilon will not have an effect when comparing nested objects.")
self.truncate_datetime = get_truncate_datetime(truncate_datetime)
self.number_format_notation = number_format_notation
if verbose_level in {0, 1, 2}:
Expand Down Expand Up @@ -1927,8 +1929,13 @@ def _diff_numbers(self, level, local_tree=None, report_type_change=True):
if self.use_log_scale:
if not logarithmic_similarity(level.t1, level.t2, threshold=self.log_scale_similarity_threshold):
self._report_result('values_changed', level, local_tree=local_tree)
elif self.math_epsilon is not None:
if not is_close(level.t1, level.t2, abs_tol=self.math_epsilon):
elif self.math_epsilon is not None or self.math_rel_epsilon is not None:
tolerances = {}
if self.math_epsilon is not None:
tolerances['abs_tol'] = self.math_epsilon
if self.math_rel_epsilon is not None:
tolerances['rel_tol'] = self.math_rel_epsilon
if not is_close(level.t1, level.t2, **tolerances):
self._report_result('values_changed', level, local_tree=local_tree)
elif self.significant_digits is None:
if level.t1 != level.t2:
Expand Down
3 changes: 3 additions & 0 deletions deepdiff/docstrings/diff_doc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ max_diffs: Integer, default = None
math_epsilon: Decimal, default = None
:ref:`math_epsilon_label` uses Python's built in Math.isclose. It defines a tolerance value which is passed to math.isclose(). Any numbers that are within the tolerance will not report as being different. Any numbers outside of that tolerance will show up as different.

math_rel_epsilon: Decimal, default = None
:ref:`math_rel_epsilon_label` is the relative counterpart of math_epsilon. It is passed to math.isclose() as rel_tol. Any numbers whose difference is within that fraction of the larger of the two magnitudes will not report as being different. It can be combined with math_epsilon.

number_format_notation : string, default="f"
:ref:`number_format_notation_label` is what defines the meaning of significant digits. The default value of "f" means the digits AFTER the decimal point. "f" stands for fixed point. The other option is "e" which stands for exponent notation or scientific notation.

Expand Down
27 changes: 27 additions & 0 deletions deepdiff/docstrings/numbers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,33 @@ Example with Fraction:
math_epsilon cannot currently handle the hashing of values, which is done when :ref:`ignore_order_label` is True.


.. _math_rel_epsilon_label:

Math Relative Epsilon
---------------------

math_rel_epsilon: Decimal, default = None
math_rel_epsilon is the relative counterpart of :ref:`math_epsilon_label`. It is passed to Python's built in math.isclose() as rel_tol. Two numbers are considered the same if their difference is no more than math_rel_epsilon times the larger of their absolute values. This is useful when the values being compared span different orders of magnitude and a single absolute tolerance does not fit all of them.

>>> from deepdiff import DeepDiff
>>> d1 = {"small": 0.001, "big": 1000000}
>>> d2 = {"small": 0.00101, "big": 1010000}
>>> DeepDiff(d1, d2, math_rel_epsilon=0.02)
{}
>>> DeepDiff(d1, d2, math_rel_epsilon=0.001)
{'values_changed': {"root['small']": {'new_value': 0.00101, 'old_value': 0.001}, "root['big']": {'new_value': 1010000, 'old_value': 1000000}}}

math_rel_epsilon and math_epsilon can be used together. Just like math.isclose(), the numbers are considered the same if they are within either tolerance. When only math_rel_epsilon is set, no absolute tolerance is applied, so a non-zero number is never considered close to zero. Set math_epsilon as well if you need that.

>>> DeepDiff(0.0, 1e-12, math_rel_epsilon=0.01)
{'values_changed': {'root': {'new_value': 1e-12, 'old_value': 0.0}}}
>>> DeepDiff(0.0, 1e-12, math_rel_epsilon=0.01, math_epsilon=1e-9)
{}

.. note::
Like math_epsilon, math_rel_epsilon cannot currently handle the hashing of values, which is done when :ref:`ignore_order_label` is True.


.. _use_log_scale_label:

Use Log Scale
Expand Down
12 changes: 12 additions & 0 deletions tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ def test_command_math_epsilon(self):
assert 0 == diffed2.exit_code
assert '{\n "values_changed": {\n "root[2][2]": {\n "new_value": 0.289,\n "old_value": 0.288\n }\n }\n}\n' == diffed2.output

def test_command_math_rel_epsilon(self):
t1 = os.path.join(FIXTURES_DIR, 'd_t1.yaml')
t2 = os.path.join(FIXTURES_DIR, 'd_t2.yaml')
runner = CliRunner()
diffed = runner.invoke(diff, [t1, t2, '--math-rel-epsilon', '0.01'])
assert 0 == diffed.exit_code
assert '{}\n' == diffed.output

diffed2 = runner.invoke(diff, [t1, t2, '--math-rel-epsilon', '0.001'])
assert 0 == diffed2.exit_code
assert '{\n "values_changed": {\n "root[2][2]": {\n "new_value": 0.289,\n "old_value": 0.288\n }\n }\n}\n' == diffed2.output

def test_command_grep(self):
path = os.path.join(FIXTURES_DIR, 'd_t1.yaml')
runner = CliRunner()
Expand Down
64 changes: 64 additions & 0 deletions tests/test_diff_math.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from decimal import Decimal
from unittest import mock
from deepdiff import DeepDiff


Expand Down Expand Up @@ -124,3 +125,66 @@ def test_ignore_numeric_type_changes_with_numeric_keys_and_no_significant_digits
# This should not crash and should treat 1 and 1.0 as the same key
result = DeepDiff(d1, d2, ignore_numeric_type_changes=True, significant_digits=None)
assert result == {}

def test_math_rel_diff(self):
"""math_rel_epsilon scales the tolerance with the magnitude of the numbers."""
d1 = {"small": 0.001, "big": 1000000}
d2 = {"small": 0.00101, "big": 1010000}
res = DeepDiff(d1, d2, math_rel_epsilon=0.02)
assert res == {}
# The same absolute tolerance can't cover both values.
res = DeepDiff(d1, d2, math_epsilon=0.02)
assert res == {"values_changed": {"root['big']": {"new_value": 1010000, "old_value": 1000000}}}
res = DeepDiff(d1, d2, math_rel_epsilon=0.001)
expected = {
"values_changed": {
"root['small']": {"new_value": 0.00101, "old_value": 0.001},
"root['big']": {"new_value": 1010000, "old_value": 1000000},
}
}
assert res == expected

def test_math_rel_diff_decimal(self):
d1 = {"a": Decimal("7.175")}
d2 = {"a": Decimal("7.174")}
res = DeepDiff(d1, d2, math_rel_epsilon=0.001)
assert res == {}
res = DeepDiff(d1, d2, math_rel_epsilon=0.0001)
expected = {
"values_changed": {
"root['a']": {
"new_value": Decimal("7.174"),
"old_value": Decimal("7.175"),
}
}
}
assert res == expected

def test_math_rel_diff_near_zero(self):
"""A relative tolerance alone never considers a non-zero number close to zero."""
res = DeepDiff(0.0, 1e-12, math_rel_epsilon=0.01)
assert res == {"values_changed": {"root": {"new_value": 1e-12, "old_value": 0.0}}}
res = DeepDiff(0.0, 1e-12, math_rel_epsilon=0.01, math_epsilon=1e-9)
assert res == {}

def test_math_rel_and_abs_diff(self):
"""Numbers are the same if they are within either tolerance, like math.isclose."""
d1 = [0.001, 1000]
d2 = [0.002, 1001]
res = DeepDiff(d1, d2, math_epsilon=0.01, math_rel_epsilon=0.01)
assert res == {}
res = DeepDiff(d1, d2, math_epsilon=0.01)
assert res == {"values_changed": {"root[1]": {"new_value": 1001, "old_value": 1000}}}
res = DeepDiff(d1, d2, math_rel_epsilon=0.01)
assert res == {"values_changed": {"root[0]": {"new_value": 0.002, "old_value": 0.001}}}

@mock.patch('deepdiff.diff.logger')
def test_math_rel_diff_ignore_order_warning(self, mock_logger):
d1 = {"a": [Decimal("9.709999618320632"), Decimal("9.709999618320632")]}
d2 = {"a": [Decimal("9.710000038146973"), Decimal("9.709999618320632")]}
res = DeepDiff(d1, d2, ignore_order=True, math_rel_epsilon=0.0001)
expected = {
"iterable_item_added": {"root['a'][0]": Decimal("9.710000038146973")}
}
assert res == expected
assert "math_rel_epsilon will not have an effect when comparing nested objects" in mock_logger.warning.call_args[0][0]
9 changes: 9 additions & 0 deletions tests/test_ignore_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,15 @@ def test_math_epsilon_when_ignore_order_in_nested_list(self):
expected = {'values_changed': {'root[0]': {'new_value': {'x': 0.0011}, 'old_value': {'x': 0.001}}, 'root[1]': {'new_value': {'y': 2}, 'old_value': {'y': 2.00002}}}}
assert expected == diff

def test_math_rel_epsilon_when_ignore_order_in_list(self):
a = [1000.0, 2]
b = [2, 1001.0]
diff = DeepDiff(a, b, ignore_order=True)
assert {'values_changed': {'root[0]': {'new_value': 1001.0, 'old_value': 1000.0}}} == diff

diff2 = DeepDiff(a, b, ignore_order=True, math_rel_epsilon=0.01)
assert {} == diff2

def test_datetime_and_ignore_order(self):
diff = DeepDiff(
[{'due_date': datetime.date(2024, 2, 1)}],
Expand Down