Skip to content

Commit 000ec0b

Browse files
committed
handling timezone. We assume any timezone naive datetime is in UTC.
1 parent 83dcad7 commit 000ec0b

File tree

5 files changed

+83
-17
lines changed

5 files changed

+83
-17
lines changed

deepdiff/diff.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import difflib
99
import logging
1010
import types
11+
import datetime
1112
from enum import Enum
1213
from copy import deepcopy
1314
from math import isclose as is_close
@@ -1487,7 +1488,15 @@ def _diff_numbers(self, level, local_tree=None, report_type_change=True):
14871488
if t1_s != t2_s:
14881489
self._report_result('values_changed', level, local_tree=local_tree)
14891490

1490-
def _diff_datetimes(self, level, local_tree=None):
1491+
def _diff_datetime(self, level, local_tree=None):
1492+
"""Diff DateTimes"""
1493+
level.t1 = datetime_normalize(self.truncate_datetime, level.t1)
1494+
level.t2 = datetime_normalize(self.truncate_datetime, level.t2)
1495+
1496+
if level.t1 != level.t2:
1497+
self._report_result('values_changed', level, local_tree=local_tree)
1498+
1499+
def _diff_time(self, level, local_tree=None):
14911500
"""Diff DateTimes"""
14921501
if self.truncate_datetime:
14931502
level.t1 = datetime_normalize(self.truncate_datetime, level.t1)
@@ -1670,8 +1679,11 @@ def _diff(self, level, parents_ids=frozenset(), _original_type=None, local_tree=
16701679
elif isinstance(level.t1, strings):
16711680
self._diff_str(level, local_tree=local_tree)
16721681

1673-
elif isinstance(level.t1, datetimes):
1674-
self._diff_datetimes(level, local_tree=local_tree)
1682+
elif isinstance(level.t1, datetime.datetime):
1683+
self._diff_datetime(level, local_tree=local_tree)
1684+
1685+
elif isinstance(level.t1, (datetime.date, datetime.timedelta, datetime.time)):
1686+
self._diff_time(level, local_tree=local_tree)
16751687

16761688
elif isinstance(level.t1, uuids):
16771689
self._diff_uuids(level, local_tree=local_tree)

deepdiff/helper.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,12 +623,29 @@ def datetime_normalize(truncate_datetime, obj):
623623
elif truncate_datetime == 'day':
624624
obj = obj.replace(hour=0, minute=0, second=0, microsecond=0)
625625
if isinstance(obj, datetime.datetime):
626-
obj = obj.replace(tzinfo=datetime.timezone.utc)
626+
if has_timezone(obj):
627+
obj = obj.astimezone(datetime.timezone.utc)
628+
else:
629+
obj = obj.replace(tzinfo=datetime.timezone.utc)
627630
elif isinstance(obj, datetime.time):
628631
obj = time_to_seconds(obj)
629632
return obj
630633

631634

635+
def has_timezone(dt):
636+
"""
637+
Function to check if a datetime object has a timezone
638+
639+
Checking dt.tzinfo.utcoffset(dt) ensures that the datetime object is truly timezone-aware
640+
because some datetime objects may have a tzinfo attribute that is not None but still
641+
doesn't provide a valid offset.
642+
643+
Certain tzinfo objects, such as pytz.timezone(None), can exist but do not provide meaningful UTC offset information.
644+
If tzinfo is present but calling .utcoffset(dt) returns None, the datetime is not truly timezone-aware.
645+
"""
646+
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
647+
648+
632649
def get_truncate_datetime(truncate_datetime):
633650
"""
634651
Validates truncate_datetime value

tests/test_diff_datetime.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from datetime import date, datetime, time
1+
import pytz
2+
from datetime import date, datetime, time, timezone
23
from deepdiff import DeepDiff
34

45

@@ -19,8 +20,8 @@ def test_datetime_diff(self):
1920
expected = {
2021
"values_changed": {
2122
"root['a']": {
22-
"new_value": datetime(2023, 7, 5, 11, 11, 12),
23-
"old_value": datetime(2023, 7, 5, 10, 11, 12),
23+
"new_value": datetime(2023, 7, 5, 11, 11, 12, tzinfo=timezone.utc),
24+
"old_value": datetime(2023, 7, 5, 10, 11, 12, tzinfo=timezone.utc),
2425
}
2526
}
2627
}
@@ -73,3 +74,27 @@ def test_time_diff(self):
7374
}
7475
}
7576
assert res == expected
77+
78+
def test_diffs_datetimes_different_timezones(self):
79+
dt_utc = datetime(2025, 2, 3, 12, 0, 0, tzinfo=pytz.utc) # UTC timezone
80+
# Convert it to another timezone (e.g., New York)
81+
dt_ny = dt_utc.astimezone(pytz.timezone('America/New_York'))
82+
assert dt_utc == dt_ny
83+
diff = DeepDiff(dt_utc, dt_ny)
84+
assert not diff
85+
86+
t1 = [dt_utc, dt_ny]
87+
t2 = [dt_ny, dt_utc]
88+
assert not DeepDiff(t1, t2)
89+
assert not DeepDiff(t1, t2, ignore_order=True)
90+
91+
t2 = [dt_ny, dt_utc, dt_ny]
92+
assert not DeepDiff(t1, t2, ignore_order=True)
93+
94+
def test_datetime_within_array_with_timezone_diff(self):
95+
d1 = [datetime(2020, 8, 31, 13, 14, 1)]
96+
d2 = [datetime(2020, 8, 31, 13, 14, 1, tzinfo=timezone.utc)]
97+
98+
assert not DeepDiff(d1, d2)
99+
assert not DeepDiff(d1, d2, ignore_order=True)
100+
assert not DeepDiff(d1, d2, truncate_datetime='second')

tests/test_diff_text.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,7 +1446,8 @@ def test_ignore_type_in_groups_str_and_datetime(self):
14461446
t1 = [1, 2, 3, 'a', now]
14471447
t2 = [1, 2, 3, 'a', 'now']
14481448
ddiff = DeepDiff(t1, t2, ignore_type_in_groups=[(str, bytes, datetime.datetime)])
1449-
result = {'values_changed': {'root[4]': {'new_value': 'now', 'old_value': now}}}
1449+
now_utc = now.replace(tzinfo=datetime.timezone.utc)
1450+
result = {'values_changed': {'root[4]': {'new_value': 'now', 'old_value': now_utc}}}
14501451
assert result == ddiff
14511452

14521453
def test_ignore_type_in_groups_float_vs_decimal(self):
@@ -2146,20 +2147,20 @@ def test_diffs_rrules(self):
21462147
assert d == {
21472148
"values_changed": {
21482149
"root[0]": {
2149-
"new_value": datetime.datetime(2011, 12, 31, 0, 0),
2150-
"old_value": datetime.datetime(2014, 12, 31, 0, 0),
2150+
"new_value": datetime.datetime(2011, 12, 31, 0, 0, tzinfo=datetime.timezone.utc),
2151+
"old_value": datetime.datetime(2014, 12, 31, 0, 0, tzinfo=datetime.timezone.utc),
21512152
},
21522153
"root[1]": {
2153-
"new_value": datetime.datetime(2012, 1, 31, 0, 0),
2154-
"old_value": datetime.datetime(2015, 1, 31, 0, 0),
2154+
"new_value": datetime.datetime(2012, 1, 31, 0, 0, tzinfo=datetime.timezone.utc),
2155+
"old_value": datetime.datetime(2015, 1, 31, 0, 0, tzinfo=datetime.timezone.utc),
21552156
},
21562157
"root[2]": {
2157-
"new_value": datetime.datetime(2012, 3, 31, 0, 0),
2158-
"old_value": datetime.datetime(2015, 3, 31, 0, 0),
2158+
"new_value": datetime.datetime(2012, 3, 31, 0, 0, tzinfo=datetime.timezone.utc),
2159+
"old_value": datetime.datetime(2015, 3, 31, 0, 0, tzinfo=datetime.timezone.utc),
21592160
},
21602161
"root[3]": {
2161-
"new_value": datetime.datetime(2012, 5, 31, 0, 0),
2162-
"old_value": datetime.datetime(2015, 5, 31, 0, 0),
2162+
"new_value": datetime.datetime(2012, 5, 31, 0, 0, tzinfo=datetime.timezone.utc),
2163+
"old_value": datetime.datetime(2015, 5, 31, 0, 0, tzinfo=datetime.timezone.utc),
21632164
},
21642165
},
21652166
"iterable_item_removed": {"root[4]": datetime.datetime(2015, 7, 31, 0, 0)},

tests/test_hash.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env python
22
import re
33
import pytest
4-
from pathlib import Path
4+
import pytz
55
import logging
66
import datetime
7+
from pathlib import Path
78
from collections import namedtuple
89
from functools import partial
910
from enum import Enum
@@ -896,6 +897,16 @@ def test_list1(self):
896897
result = DeepHash(obj, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
897898
assert expected_result == result
898899

900+
def test_datetime_hash(self):
901+
dt_utc = datetime.datetime(2025, 2, 3, 12, 0, 0, tzinfo=pytz.utc) # UTC timezone
902+
# Convert it to another timezone (e.g., New York)
903+
dt_ny = dt_utc.astimezone(pytz.timezone('America/New_York'))
904+
assert dt_utc == dt_ny
905+
906+
result_utc = DeepHash(dt_utc, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
907+
result_ny = DeepHash(dt_ny, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
908+
assert result_utc[dt_utc] == result_ny[dt_ny]
909+
899910
def test_dict1(self):
900911
string1 = "a"
901912
key1 = "key1"

0 commit comments

Comments
 (0)