From 8980ea547e2c8d4b49cd8d979cdb6d1cf2834d57 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Sun, 6 Sep 2026 12:37:17 +0900 Subject: [PATCH] Fix numeric formatting of temporal dictionary keys Keep temporal objects out of numeric rounding at the shared formatter. Cover direct and unordered dictionary comparison, distinct temporal keys, and formatting precision while preserving numeric behavior. Fixes: https://github.com/qlustered/deepdiff/issues/550 --- deepdiff/docstrings/numbers.rst | 5 +++++ deepdiff/helper.py | 7 ++++-- tests/test_diff_datetime.py | 38 ++++++++++++++++++++++++++++++++- tests/test_helper.py | 14 ++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/deepdiff/docstrings/numbers.rst b/deepdiff/docstrings/numbers.rst index c14fe5ef..ae8d5694 100644 --- a/deepdiff/docstrings/numbers.rst +++ b/deepdiff/docstrings/numbers.rst @@ -83,6 +83,11 @@ Approximate number comparison (significant_digits after the decimal point in sci Number To String Function ------------------------- +The default formatter rounds numeric values only. Date, time, datetime, +timedelta, and NumPy datetime values pass through unchanged, including when +dictionary-key comparison uses numeric precision or ignores numeric types. +Numeric precision does not round or merge distinct temporal keys. + number_to_string_func : function, default=None In many cases DeepDiff converts numbers to strings in order to compare them. For example when ignore_order=True, when significant digits parameter is defined or when the ignore_numeric_type_changes=True. In its simplest form, the number_to_string_func is "{:.Xf}".format(Your Number) where X is the significant digits and the number_format_notation is left as the default of "f" meaning fixed point. diff --git a/deepdiff/helper.py b/deepdiff/helper.py index 5143a3b7..90fa2683 100644 --- a/deepdiff/helper.py +++ b/deepdiff/helper.py @@ -468,14 +468,17 @@ def get_doc(doc_filename: str) -> str: def number_to_string(number: Any, significant_digits: int, number_format_notation: Literal['f', 'e'] = 'f') -> Any: """ - Convert numbers to string considering significant digits. + Convert numeric values to strings considering significant digits. + + Temporal objects belong to the broader comparison ``numbers`` group, but + are not numeric values to round. Preserve them like other non-numeric input. """ try: using = number_formatting[number_format_notation] except KeyError: raise ValueError("number_format_notation got invalid value of {}. The valid values are 'f' and 'e'".format(number_format_notation)) from None - if not isinstance(number, numbers): # type: ignore + if not isinstance(number, only_numbers): # type: ignore return number elif isinstance(number, Decimal): with localcontext() as ctx: diff --git a/tests/test_diff_datetime.py b/tests/test_diff_datetime.py index c3905291..728c89e7 100644 --- a/tests/test_diff_datetime.py +++ b/tests/test_diff_datetime.py @@ -1,5 +1,7 @@ import pytz -from datetime import date, datetime, time, timezone +import pytest +import numpy as np +from datetime import date, datetime, time, timedelta, timezone from deepdiff import DeepDiff @@ -123,3 +125,37 @@ def test_datetime_within_array_with_timezone_diff(self): assert not DeepDiff(d1, d2) assert not DeepDiff(d1, d2, ignore_order=True) assert not DeepDiff(d1, d2, truncate_datetime='second') + + +@pytest.mark.parametrize("key, other_key", [ + (datetime(2020, 5, 17, 22, 15), datetime(2020, 5, 17, 22, 15, 0, 1)), + (datetime(2020, 5, 17, tzinfo=timezone.utc), datetime(2020, 5, 17, microsecond=1, tzinfo=timezone.utc)), + (date(2020, 5, 17), date(2020, 5, 18)), + (time(22, 15), time(22, 15, microsecond=1)), + (timedelta(seconds=1), timedelta(seconds=1, microseconds=1)), + (np.datetime64('2020-05-17T22:15:00.000000'), np.datetime64('2020-05-17T22:15:00.000001')), +]) +@pytest.mark.parametrize("flag", [ + "ignore_numeric_type_changes", "ignore_string_case", "ignore_string_type_changes", +]) +@pytest.mark.parametrize("ignore_order", [False, True]) +def test_temporal_dict_keys_preserve_identity_with_numeric_precision(key, other_key, flag, ignore_order): + """Key cleaning must not round temporal keys or merge distinct instants.""" + t1 = {key: 1, other_key: 2} + t2 = {key: 1, other_key: 3} + if ignore_order: + t1, t2 = [t1], [t2] + result = DeepDiff(t1, t2, significant_digits=0, view='tree', ignore_order=ignore_order, **{flag: True}) + assert set(result) == {'values_changed'} + changes = list(result['values_changed']) + assert len(changes) == 1 + assert changes[0].t1 == 2 + assert changes[0].t2 == 3 + assert changes[0].up.t1 == {key: 1, other_key: 2} + assert changes[0].up.t2 == {key: 1, other_key: 3} + + +def test_datetime_key_with_ignored_numeric_value_types(): + """Reproduce issue 550 through the public comparison API.""" + key = datetime(2020, 5, 17, 22, 15) + assert DeepDiff({key: 10.0}, {key: 10}, ignore_numeric_type_changes=True) == {} diff --git a/tests/test_helper.py b/tests/test_helper.py index a42407bd..2f2046bd 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -304,3 +304,17 @@ def test_add_root_to_paths(self, test_num, value, expected): def test_get_semvar_as_integer(self, test_num, value, expected): result = get_semvar_as_integer(value) assert expected == result, f"test_get_semvar_as_integer #{test_num} failed." + + +@pytest.mark.parametrize("value", [ + datetime.datetime(2020, 5, 17, microsecond=123456), + datetime.date(2020, 5, 17), + datetime.time(22, 15, microsecond=123456), + datetime.timedelta(seconds=1, microseconds=123456), + np.datetime64('2020-05-17T22:15:00.123456'), +]) +@pytest.mark.parametrize("significant_digits", [0, 3, 55]) +@pytest.mark.parametrize("number_format_notation", ['f', 'e']) +def test_number_to_string_preserves_temporal_objects(value, significant_digits, number_format_notation): + """Non-numeric temporal values pass through the shared formatter unchanged.""" + assert number_to_string(value, significant_digits, number_format_notation) is value