Skip to content

Commit

Permalink
handling timezone. We assume any timezone naive datetime is in UTC.
Browse files Browse the repository at this point in the history
  • Loading branch information
seperman committed Feb 4, 2025
1 parent 83dcad7 commit 000ec0b
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 17 deletions.
18 changes: 15 additions & 3 deletions deepdiff/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import difflib
import logging
import types
import datetime
from enum import Enum
from copy import deepcopy
from math import isclose as is_close
Expand Down Expand Up @@ -1487,7 +1488,15 @@ def _diff_numbers(self, level, local_tree=None, report_type_change=True):
if t1_s != t2_s:
self._report_result('values_changed', level, local_tree=local_tree)

def _diff_datetimes(self, level, local_tree=None):
def _diff_datetime(self, level, local_tree=None):
"""Diff DateTimes"""
level.t1 = datetime_normalize(self.truncate_datetime, level.t1)
level.t2 = datetime_normalize(self.truncate_datetime, level.t2)

if level.t1 != level.t2:
self._report_result('values_changed', level, local_tree=local_tree)

def _diff_time(self, level, local_tree=None):
"""Diff DateTimes"""
if self.truncate_datetime:
level.t1 = datetime_normalize(self.truncate_datetime, level.t1)
Expand Down Expand Up @@ -1670,8 +1679,11 @@ def _diff(self, level, parents_ids=frozenset(), _original_type=None, local_tree=
elif isinstance(level.t1, strings):
self._diff_str(level, local_tree=local_tree)

elif isinstance(level.t1, datetimes):
self._diff_datetimes(level, local_tree=local_tree)
elif isinstance(level.t1, datetime.datetime):
self._diff_datetime(level, local_tree=local_tree)

elif isinstance(level.t1, (datetime.date, datetime.timedelta, datetime.time)):
self._diff_time(level, local_tree=local_tree)

elif isinstance(level.t1, uuids):
self._diff_uuids(level, local_tree=local_tree)
Expand Down
19 changes: 18 additions & 1 deletion deepdiff/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,12 +623,29 @@ def datetime_normalize(truncate_datetime, obj):
elif truncate_datetime == 'day':
obj = obj.replace(hour=0, minute=0, second=0, microsecond=0)
if isinstance(obj, datetime.datetime):
obj = obj.replace(tzinfo=datetime.timezone.utc)
if has_timezone(obj):
obj = obj.astimezone(datetime.timezone.utc)
else:
obj = obj.replace(tzinfo=datetime.timezone.utc)
elif isinstance(obj, datetime.time):
obj = time_to_seconds(obj)
return obj


def has_timezone(dt):
"""
Function to check if a datetime object has a timezone
Checking dt.tzinfo.utcoffset(dt) ensures that the datetime object is truly timezone-aware
because some datetime objects may have a tzinfo attribute that is not None but still
doesn't provide a valid offset.
Certain tzinfo objects, such as pytz.timezone(None), can exist but do not provide meaningful UTC offset information.
If tzinfo is present but calling .utcoffset(dt) returns None, the datetime is not truly timezone-aware.
"""
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None


def get_truncate_datetime(truncate_datetime):
"""
Validates truncate_datetime value
Expand Down
31 changes: 28 additions & 3 deletions tests/test_diff_datetime.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import date, datetime, time
import pytz
from datetime import date, datetime, time, timezone
from deepdiff import DeepDiff


Expand All @@ -19,8 +20,8 @@ def test_datetime_diff(self):
expected = {
"values_changed": {
"root['a']": {
"new_value": datetime(2023, 7, 5, 11, 11, 12),
"old_value": datetime(2023, 7, 5, 10, 11, 12),
"new_value": datetime(2023, 7, 5, 11, 11, 12, tzinfo=timezone.utc),
"old_value": datetime(2023, 7, 5, 10, 11, 12, tzinfo=timezone.utc),
}
}
}
Expand Down Expand Up @@ -73,3 +74,27 @@ def test_time_diff(self):
}
}
assert res == expected

def test_diffs_datetimes_different_timezones(self):
dt_utc = datetime(2025, 2, 3, 12, 0, 0, tzinfo=pytz.utc) # UTC timezone
# Convert it to another timezone (e.g., New York)
dt_ny = dt_utc.astimezone(pytz.timezone('America/New_York'))
assert dt_utc == dt_ny
diff = DeepDiff(dt_utc, dt_ny)
assert not diff

t1 = [dt_utc, dt_ny]
t2 = [dt_ny, dt_utc]
assert not DeepDiff(t1, t2)
assert not DeepDiff(t1, t2, ignore_order=True)

t2 = [dt_ny, dt_utc, dt_ny]
assert not DeepDiff(t1, t2, ignore_order=True)

def test_datetime_within_array_with_timezone_diff(self):
d1 = [datetime(2020, 8, 31, 13, 14, 1)]
d2 = [datetime(2020, 8, 31, 13, 14, 1, tzinfo=timezone.utc)]

assert not DeepDiff(d1, d2)
assert not DeepDiff(d1, d2, ignore_order=True)
assert not DeepDiff(d1, d2, truncate_datetime='second')
19 changes: 10 additions & 9 deletions tests/test_diff_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -1446,7 +1446,8 @@ def test_ignore_type_in_groups_str_and_datetime(self):
t1 = [1, 2, 3, 'a', now]
t2 = [1, 2, 3, 'a', 'now']
ddiff = DeepDiff(t1, t2, ignore_type_in_groups=[(str, bytes, datetime.datetime)])
result = {'values_changed': {'root[4]': {'new_value': 'now', 'old_value': now}}}
now_utc = now.replace(tzinfo=datetime.timezone.utc)
result = {'values_changed': {'root[4]': {'new_value': 'now', 'old_value': now_utc}}}
assert result == ddiff

def test_ignore_type_in_groups_float_vs_decimal(self):
Expand Down Expand Up @@ -2146,20 +2147,20 @@ def test_diffs_rrules(self):
assert d == {
"values_changed": {
"root[0]": {
"new_value": datetime.datetime(2011, 12, 31, 0, 0),
"old_value": datetime.datetime(2014, 12, 31, 0, 0),
"new_value": datetime.datetime(2011, 12, 31, 0, 0, tzinfo=datetime.timezone.utc),
"old_value": datetime.datetime(2014, 12, 31, 0, 0, tzinfo=datetime.timezone.utc),
},
"root[1]": {
"new_value": datetime.datetime(2012, 1, 31, 0, 0),
"old_value": datetime.datetime(2015, 1, 31, 0, 0),
"new_value": datetime.datetime(2012, 1, 31, 0, 0, tzinfo=datetime.timezone.utc),
"old_value": datetime.datetime(2015, 1, 31, 0, 0, tzinfo=datetime.timezone.utc),
},
"root[2]": {
"new_value": datetime.datetime(2012, 3, 31, 0, 0),
"old_value": datetime.datetime(2015, 3, 31, 0, 0),
"new_value": datetime.datetime(2012, 3, 31, 0, 0, tzinfo=datetime.timezone.utc),
"old_value": datetime.datetime(2015, 3, 31, 0, 0, tzinfo=datetime.timezone.utc),
},
"root[3]": {
"new_value": datetime.datetime(2012, 5, 31, 0, 0),
"old_value": datetime.datetime(2015, 5, 31, 0, 0),
"new_value": datetime.datetime(2012, 5, 31, 0, 0, tzinfo=datetime.timezone.utc),
"old_value": datetime.datetime(2015, 5, 31, 0, 0, tzinfo=datetime.timezone.utc),
},
},
"iterable_item_removed": {"root[4]": datetime.datetime(2015, 7, 31, 0, 0)},
Expand Down
13 changes: 12 additions & 1 deletion tests/test_hash.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env python
import re
import pytest
from pathlib import Path
import pytz
import logging
import datetime
from pathlib import Path
from collections import namedtuple
from functools import partial
from enum import Enum
Expand Down Expand Up @@ -896,6 +897,16 @@ def test_list1(self):
result = DeepHash(obj, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
assert expected_result == result

def test_datetime_hash(self):
dt_utc = datetime.datetime(2025, 2, 3, 12, 0, 0, tzinfo=pytz.utc) # UTC timezone
# Convert it to another timezone (e.g., New York)
dt_ny = dt_utc.astimezone(pytz.timezone('America/New_York'))
assert dt_utc == dt_ny

result_utc = DeepHash(dt_utc, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
result_ny = DeepHash(dt_ny, ignore_string_type_changes=True, hasher=DeepHash.sha1hex)
assert result_utc[dt_utc] == result_ny[dt_ny]

def test_dict1(self):
string1 = "a"
key1 = "key1"
Expand Down

0 comments on commit 000ec0b

Please sign in to comment.