diff --git a/deepdiff/helper.py b/deepdiff/helper.py index 1b01931e..caf05a87 100644 --- a/deepdiff/helper.py +++ b/deepdiff/helper.py @@ -13,6 +13,7 @@ from collections.abc import Mapping, Sequence, Generator from ast import literal_eval from decimal import Decimal, localcontext, InvalidOperation as InvalidDecimalOperation +from fractions import Fraction from itertools import repeat from orderly_set import StableSetEq as SetOrderedBase # median: 1.0867 s for cache test, 5.63s for all tests from threading import Timer @@ -187,14 +188,14 @@ def get_semvar_as_integer(version: str) -> int: unicode_type = str bytes_type = bytes only_complex_number: Tuple[Type[Any], ...] = (complex,) + numpy_complex_numbers -only_numbers: Tuple[Type[Any], ...] = (int, float, complex, Decimal) + numpy_numbers +only_numbers: Tuple[Type[Any], ...] = (int, float, complex, Decimal, Fraction) + numpy_numbers datetimes: Tuple[Type[Any], ...] = (datetime.datetime, datetime.date, datetime.timedelta, datetime.time, np_datetime64) ipranges: Tuple[Type[Any], ...] = (ipaddress.IPv4Interface, ipaddress.IPv6Interface, ipaddress.IPv4Network, ipaddress.IPv6Network, ipaddress.IPv4Address, ipaddress.IPv6Address) uuids: Tuple[Type[uuid.UUID]] = (uuid.UUID, ) times: Tuple[Type[Any], ...] = (datetime.datetime, datetime.time, np_datetime64) numbers: Tuple[Type[Any], ...] = only_numbers + datetimes # Type alias for use in type annotations -NumberType = Union[int, float, complex, Decimal, datetime.datetime, datetime.date, datetime.timedelta, datetime.time, Any] +NumberType = Union[int, float, complex, Decimal, Fraction, datetime.datetime, datetime.date, datetime.timedelta, datetime.time, Any] booleans: Tuple[Type[bool], Type[Any]] = (bool, np_bool_) basic_types: Tuple[Type[Any], ...] = strings + numbers + uuids + booleans + (type(None), ) @@ -433,6 +434,11 @@ def number_to_string(number: Any, significant_digits: int, number_format_notatio # For example '999.99999999' will become '1000.000000' after quantize ctx.prec += 1 number = number.quantize(Decimal('0.' + '0' * significant_digits)) + elif isinstance(number, Fraction): + # Convert Fraction to float so that string formatting works on Python < 3.12 + number = round(float(number), significant_digits) + if significant_digits == 0: + number = int(number) elif isinstance(number, only_complex_number): # type: ignore # Case for complex numbers. number = number.__class__( diff --git a/deepdiff/serialization.py b/deepdiff/serialization.py index 49d24d23..f78314bb 100644 --- a/deepdiff/serialization.py +++ b/deepdiff/serialization.py @@ -11,6 +11,7 @@ import decimal # NOQA import orderly_set # NOQA import collections # NOQA +import fractions import ipaddress import base64 from copy import deepcopy, copy @@ -79,6 +80,7 @@ class UnsupportedFormatErr(TypeError): 'datetime.time', 'datetime.timedelta', 'decimal.Decimal', + 'fractions.Fraction', 'uuid.UUID', 'orderly_set.sets.OrderedSet', 'orderly_set.sets.OrderlySet', @@ -635,6 +637,13 @@ def _serialize_decimal(value): return float(value) +def _serialize_fraction(value): + if value.denominator == 1: + return value.numerator + else: + return float(value) + + def _serialize_tuple(value): if hasattr(value, '_asdict'): # namedtuple return value._asdict() @@ -655,6 +664,7 @@ def _serialize_bytes(value): JSON_CONVERTOR = { decimal.Decimal: _serialize_decimal, + fractions.Fraction: _serialize_fraction, SetOrdered: list, orderly_set.StableSetEq: list, set: list, diff --git a/docs/ignore_types_or_values.rst b/docs/ignore_types_or_values.rst index c1beb759..9bddb123 100644 --- a/docs/ignore_types_or_values.rst +++ b/docs/ignore_types_or_values.rst @@ -65,18 +65,30 @@ Ignore Numeric Type Changes ignore_numeric_type_changes: Boolean, default = False Whether to ignore numeric type changes or not. For example 10 vs. 10.0 are considered the same if ignore_numeric_type_changes is set to True. -Example +Example with Decimal >>> from decimal import Decimal >>> from deepdiff import DeepDiff - >>> + >>> >>> t1 = Decimal('10.01') >>> t2 = 10.01 - >>> + >>> >>> DeepDiff(t1, t2) {'type_changes': {'root': {'old_type': , 'new_type': , 'old_value': Decimal('10.01'), 'new_value': 10.01}}} >>> DeepDiff(t1, t2, ignore_numeric_type_changes=True) {} +Example with Fraction + >>> from fractions import Fraction + >>> from deepdiff import DeepDiff + >>> + >>> t1 = Fraction(1, 2) + >>> t2 = 0.5 + >>> + >>> DeepDiff(t1, t2) + {'type_changes': {'root': {'old_type': , 'new_type': , 'old_value': Fraction(1, 2), 'new_value': 0.5}}} + >>> DeepDiff(t1, t2, ignore_numeric_type_changes=True) + {} + Note that this parameter only works for comparing numbers with numbers. If you compare a number to a string value of the number, this parameter does not solver your problem: Example: diff --git a/docs/numbers.rst b/docs/numbers.rst index 0c616ad8..089bf84d 100644 --- a/docs/numbers.rst +++ b/docs/numbers.rst @@ -41,6 +41,15 @@ Approximate decimals comparison (Significant digits after the point): >>> DeepDiff(t1, t2, significant_digits=1) {'values_changed': {'root': {'new_value': Decimal('1.57'), 'old_value': Decimal('1.52')}}} +Approximate fractions comparison (Significant digits after the point): + >>> from fractions import Fraction + >>> t1 = Fraction(22, 7) # 3.142857... + >>> t2 = Fraction(355, 113) # 3.141592... + >>> DeepDiff(t1, t2, significant_digits=2) + {} + >>> DeepDiff(t1, t2, significant_digits=3) + {'values_changed': {'root': {'new_value': Fraction(355, 113), 'old_value': Fraction(22, 7)}}} + Approximate float comparison (Significant digits after the point): >>> t1 = [ 1.1129, 1.3359 ] >>> t2 = [ 1.113, 1.3362 ] @@ -131,13 +140,20 @@ math_epsilon: Decimal, default = None To check against that the math core module provides the valuable isclose() function. It evaluates the being close of two numbers to each other, with reference to an epsilon (abs_tol). This is superior to the format function, as it evaluates the mathematical representation and not the string representation. -Example: +Example with Decimal: >>> from decimal import Decimal >>> d1 = {"a": Decimal("7.175")} >>> d2 = {"a": Decimal("7.174")} >>> DeepDiff(d1, d2, math_epsilon=0.01) {} +Example with Fraction: + >>> from fractions import Fraction + >>> d1 = {"a": Fraction(7175, 1000)} + >>> d2 = {"a": Fraction(7174, 1000)} + >>> DeepDiff(d1, d2, math_epsilon=0.01) + {} + .. note:: math_epsilon cannot currently handle the hashing of values, which is done when :ref:`ignore_order_label` is True. diff --git a/tests/test_fraction.py b/tests/test_fraction.py new file mode 100644 index 00000000..b40cad29 --- /dev/null +++ b/tests/test_fraction.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python +"""Tests for fractions.Fraction support in DeepDiff.""" +import pytest +import logging +from fractions import Fraction +from decimal import Decimal +from functools import partial +from deepdiff import DeepDiff, DeepHash +from deepdiff.deephash import prepare_string_for_hashing +from deepdiff.helper import number_to_string +from deepdiff.serialization import json_dumps, json_loads + + +logging.disable(logging.CRITICAL) + +# Only the prep part of DeepHash. We don't need to test the actual hash function. +DeepHashPrep = partial(DeepHash, apply_hash=False) + + +class TestFractionDiff: + """Tests for DeepDiff with Fraction objects.""" + + def test_fraction_value_change(self): + t1 = {1: Fraction(1, 3)} + t2 = {1: Fraction(2, 3)} + ddiff = DeepDiff(t1, t2) + result = { + 'values_changed': { + 'root[1]': { + 'new_value': Fraction(2, 3), + 'old_value': Fraction(1, 3) + } + } + } + assert result == ddiff + + def test_fraction_no_change(self): + t1 = Fraction(1, 3) + t2 = Fraction(1, 3) + ddiff = DeepDiff(t1, t2) + assert {} == ddiff + + def test_fraction_vs_float_type_change(self): + t1 = Fraction(1, 2) + t2 = 0.5 + ddiff = DeepDiff(t1, t2) + assert 'type_changes' in ddiff + assert ddiff['type_changes']['root']['old_type'] == Fraction + assert ddiff['type_changes']['root']['new_type'] == float + + def test_fraction_vs_int_type_change(self): + t1 = Fraction(2, 1) + t2 = 2 + ddiff = DeepDiff(t1, t2) + assert 'type_changes' in ddiff + assert ddiff['type_changes']['root']['old_type'] == Fraction + assert ddiff['type_changes']['root']['new_type'] == int + + def test_fraction_vs_decimal_type_change(self): + t1 = Fraction(1, 2) + t2 = Decimal('0.5') + ddiff = DeepDiff(t1, t2) + assert 'type_changes' in ddiff + + def test_fraction_in_dict(self): + t1 = {"a": Fraction(1, 3), "b": Fraction(2, 3)} + t2 = {"a": Fraction(1, 3), "b": Fraction(3, 4)} + ddiff = DeepDiff(t1, t2) + assert 'values_changed' in ddiff + assert "root['b']" in ddiff['values_changed'] + + def test_fraction_in_list(self): + t1 = [Fraction(1, 2), Fraction(1, 3)] + t2 = [Fraction(1, 2), Fraction(1, 4)] + ddiff = DeepDiff(t1, t2) + result = { + 'values_changed': { + 'root[1]': { + 'new_value': Fraction(1, 4), + 'old_value': Fraction(1, 3) + } + } + } + assert result == ddiff + + def test_fraction_nested(self): + t1 = {"data": [{"val": Fraction(1, 3)}]} + t2 = {"data": [{"val": Fraction(2, 3)}]} + ddiff = DeepDiff(t1, t2) + assert 'values_changed' in ddiff + assert "root['data'][0]['val']" in ddiff['values_changed'] + + +class TestFractionIgnoreNumericTypeChanges: + """Tests for ignore_numeric_type_changes with Fraction.""" + + def test_fraction_vs_float_ignored(self): + t1 = Fraction(1, 2) + t2 = 0.5 + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + def test_fraction_vs_int_ignored(self): + t1 = Fraction(2, 1) + t2 = 2 + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + def test_fraction_vs_decimal_ignored(self): + t1 = Fraction(1, 2) + t2 = Decimal('0.5') + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + def test_fraction_vs_float_different_values(self): + t1 = Fraction(1, 3) + t2 = 0.5 + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert 'values_changed' in ddiff + + def test_fraction_vs_float_in_list_ignored(self): + t1 = [Fraction(1, 2), Fraction(3, 4)] + t2 = [0.5, 0.75] + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + def test_fraction_vs_int_in_dict_ignored(self): + t1 = {"a": Fraction(5, 1), "b": Fraction(10, 1)} + t2 = {"a": 5, "b": 10} + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + @pytest.mark.parametrize("t1, t2, significant_digits, result", [ + ([0.5], [Fraction(1, 2)], 5, {}), + ([Fraction(1, 3)], [0.333333], 5, {}), + ([Fraction(1, 3)], [Decimal('0.33333')], 5, {}), + ([1], [Fraction(1, 1)], 5, {}), + ([-Fraction(1, 2)], [-0.5], 5, {}), + ([Fraction(22, 7)], [3.14286], 4, {}), + ]) + def test_ignore_numeric_type_changes_with_fraction(self, t1, t2, significant_digits, result): + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True, significant_digits=significant_digits) + assert result == ddiff + + +class TestFractionSignificantDigits: + """Tests for significant_digits with Fraction.""" + + def test_fraction_significant_digits_equal(self): + t1 = Fraction(1, 3) # 0.333... + t2 = Fraction(334, 1000) # 0.334 + ddiff = DeepDiff(t1, t2, significant_digits=2) + assert {} == ddiff + + def test_fraction_significant_digits_different(self): + t1 = Fraction(1, 3) # 0.333... + t2 = Fraction(1, 2) # 0.5 + ddiff = DeepDiff(t1, t2, significant_digits=1) + assert 'values_changed' in ddiff + + @pytest.mark.parametrize("test_num, t1, t2, significant_digits, number_format_notation, result", [ + (1, Fraction(1, 3), Fraction(334, 1000), 2, "f", {}), + (2, Fraction(1, 2), Fraction(499, 1000), 2, "f", {}), + (3, Fraction(1, 2), Fraction(1, 3), 0, "f", {}), + (4, Fraction(1, 2), Fraction(1, 3), 1, "f", + {'values_changed': {'root': {'new_value': Fraction(1, 3), 'old_value': Fraction(1, 2)}}}), + (5, Fraction(22, 7), Fraction(355, 113), 2, "f", {}), # Two approximations of pi agree to 2 digits + (6, Fraction(22, 7), Fraction(355, 113), 3, "f", + {'values_changed': {'root': {'new_value': Fraction(355, 113), 'old_value': Fraction(22, 7)}}}), + ]) + def test_fraction_significant_digits_and_notation(self, test_num, t1, t2, significant_digits, number_format_notation, result): + ddiff = DeepDiff(t1, t2, significant_digits=significant_digits, + number_format_notation=number_format_notation) + assert result == ddiff, f"test_fraction_significant_digits_and_notation #{test_num} failed." + + +class TestFractionMathEpsilon: + """Tests for math_epsilon with Fraction.""" + + def test_fraction_math_epsilon_close(self): + d1 = {"a": Fraction(7175, 1000)} + d2 = {"a": Fraction(7174, 1000)} + res = DeepDiff(d1, d2, math_epsilon=0.01) + assert res == {} + + def test_fraction_math_epsilon_not_close(self): + d1 = {"a": Fraction(7175, 1000)} + d2 = {"a": Fraction(7174, 1000)} + res = DeepDiff(d1, d2, math_epsilon=0.0001) + assert 'values_changed' in res + + def test_fraction_vs_float_math_epsilon(self): + d1 = {"a": Fraction(1, 3)} + d2 = {"a": 0.333} + res = DeepDiff(d1, d2, math_epsilon=0.001, ignore_numeric_type_changes=True) + assert res == {} + + +class TestFractionIgnoreOrder: + """Tests for ignore_order with Fraction.""" + + def test_fraction_ignore_order(self): + t1 = [{1: Fraction(1, 3)}, {2: Fraction(2, 3)}] + t2 = [{2: Fraction(2, 3)}, {1: Fraction(1, 3)}] + ddiff = DeepDiff(t1, t2, ignore_order=True) + assert {} == ddiff + + def test_fraction_ignore_order_with_change(self): + t1 = [Fraction(1, 2), Fraction(1, 3)] + t2 = [Fraction(1, 3), Fraction(1, 4)] + ddiff = DeepDiff(t1, t2, ignore_order=True) + assert ddiff != {} + + +class TestFractionAsKey: + """Tests for Fraction used as dictionary key.""" + + def test_fraction_as_dict_key(self): + t1 = {Fraction(1, 2): "half"} + t2 = {Fraction(1, 2): "one half"} + ddiff = DeepDiff(t1, t2) + assert 'values_changed' in ddiff + + def test_fraction_vs_float_key(self): + # Fraction(1, 2) == 0.5 and hash(Fraction(1, 2)) == hash(0.5) in Python, + # so they resolve to the same dict key. DeepDiff sees no difference. + t1 = {Fraction(1, 2): "value"} + t2 = {0.5: "value"} + ddiff = DeepDiff(t1, t2) + assert ddiff == {} + + def test_fraction_vs_float_key_ignore_numeric(self): + t1 = {Fraction(1, 2): "value"} + t2 = {0.5: "value"} + ddiff = DeepDiff(t1, t2, ignore_numeric_type_changes=True) + assert {} == ddiff + + +class TestFractionNumberToString: + """Tests for number_to_string with Fraction.""" + + @pytest.mark.parametrize("t1, t2, significant_digits, number_format_notation, expected_result", [ + (Fraction(1, 3), 0.333333, 5, "f", True), + (Fraction(1, 2), 0.5, 5, "f", True), + (Fraction(1, 2), 0.5, 5, "e", True), + (Fraction(1, 3), Fraction(1, 4), 1, "f", ('0.3', '0.2')), + (Fraction(22, 7), 3.14286, 4, "f", True), + (Fraction(0), 0.0, 5, "f", True), + (Fraction(-1, 2), -0.5, 5, "f", True), + ]) + def test_number_to_string_fraction(self, t1, t2, significant_digits, number_format_notation, expected_result): + st1 = number_to_string(t1, significant_digits=significant_digits, number_format_notation=number_format_notation) + st2 = number_to_string(t2, significant_digits=significant_digits, number_format_notation=number_format_notation) + if expected_result is True: + assert st1 == st2 + else: + assert st1 == expected_result[0] + assert st2 == expected_result[1] + + +class TestFractionDeepHash: + """Tests for DeepHash with Fraction.""" + + def test_fraction_hash(self): + result = DeepHash(Fraction(1, 3)) + assert result[Fraction(1, 3)] + + def test_fraction_same_value_same_hash(self): + result1 = DeepHash(Fraction(1, 2)) + result2 = DeepHash(Fraction(1, 2)) + assert result1[Fraction(1, 2)] == result2[Fraction(1, 2)] + + def test_fraction_different_value_different_hash(self): + result1 = DeepHash(Fraction(1, 2)) + result2 = DeepHash(Fraction(1, 3)) + assert result1[Fraction(1, 2)] != result2[Fraction(1, 3)] + + def test_fraction_vs_float_hash_different_by_default(self): + result1 = DeepHash(Fraction(1, 2)) + result2 = DeepHash(0.5) + assert result1[Fraction(1, 2)] != result2[0.5] + + def test_fraction_vs_float_hash_same_with_ignore_numeric_type(self): + result1 = DeepHash(Fraction(1, 2), ignore_numeric_type_changes=True) + result2 = DeepHash(0.5, ignore_numeric_type_changes=True) + assert result1[Fraction(1, 2)] == result2[0.5] + + def test_fraction_hash_prep(self): + result = DeepHashPrep(Fraction(1, 3)) + assert 'Fraction' in result[Fraction(1, 3)] + + def test_fraction_hash_prep_ignore_numeric_type(self): + result = DeepHashPrep(Fraction(1, 2), ignore_numeric_type_changes=True) + assert 'number' in result[Fraction(1, 2)] + + def test_fraction_hash_significant_digits(self): + r1 = DeepHashPrep(Fraction(1, 3), significant_digits=2) + r2 = DeepHashPrep(Fraction(334, 1000), significant_digits=2) + assert r1[Fraction(1, 3)] == r2[Fraction(334, 1000)] + + +class TestFractionSerialization: + """Tests for JSON serialization of Fraction values.""" + + def test_fraction_to_json(self): + t1 = Fraction(1, 3) + t2 = Fraction(2, 3) + ddiff = DeepDiff(t1, t2) + json_str = ddiff.to_json() + assert json_str is not None + assert '"new_value"' in json_str + + def test_fraction_integer_value_serialization(self): + """Fraction with denominator 1 should serialize as int.""" + result = json_dumps(Fraction(5, 1)) + assert result == '5' + + def test_fraction_float_value_serialization(self): + """Fraction with denominator != 1 should serialize as float.""" + result = json_dumps(Fraction(1, 2)) + assert result == '0.5' + + def test_fraction_json_roundtrip(self): + t1 = {"a": Fraction(1, 2), "b": [1, 2]} + t2 = {"a": Fraction(3, 4), "b": [1, 3]} + ddiff = DeepDiff(t1, t2) + json_str = ddiff.to_json() + loaded = json_loads(json_str) + assert loaded is not None