diff --git a/deepdiff/commands.py b/deepdiff/commands.py index 0543aa18..98473be1 100644 --- a/deepdiff/commands.py +++ b/deepdiff/commands.py @@ -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) diff --git a/deepdiff/diff.py b/deepdiff/diff.py index e65fd4a0..4a58b8c9 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -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, @@ -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())) @@ -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}: @@ -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: diff --git a/deepdiff/docstrings/diff_doc.rst b/deepdiff/docstrings/diff_doc.rst index 5ab9d60e..a755530a 100644 --- a/deepdiff/docstrings/diff_doc.rst +++ b/deepdiff/docstrings/diff_doc.rst @@ -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. diff --git a/deepdiff/docstrings/numbers.rst b/deepdiff/docstrings/numbers.rst index c14fe5ef..3ed35720 100644 --- a/deepdiff/docstrings/numbers.rst +++ b/deepdiff/docstrings/numbers.rst @@ -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 diff --git a/tests/test_command.py b/tests/test_command.py index 25427a7b..704f5d66 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -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() diff --git a/tests/test_diff_math.py b/tests/test_diff_math.py index 9bfa3e49..ff3f3619 100644 --- a/tests/test_diff_math.py +++ b/tests/test_diff_math.py @@ -1,4 +1,5 @@ from decimal import Decimal +from unittest import mock from deepdiff import DeepDiff @@ -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] diff --git a/tests/test_ignore_order.py b/tests/test_ignore_order.py index aaceeb71..0fb619bb 100644 --- a/tests/test_ignore_order.py +++ b/tests/test_ignore_order.py @@ -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)}],