Opened 3 weeks ago
Last modified 42 hours ago
#35857 new Bug
django.utils.timesince.timesince incorrectly handles daylight saving time
Reported by: | Frank Sauerburger | Owned by: | |
---|---|---|---|
Component: | Utilities | Version: | 5.0 |
Severity: | Normal | Keywords: | |
Cc: | Triage Stage: | Accepted | |
Has patch: | no | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description
timesince computes the time elapsed between two datetimes (d and now) and returns it as a human readable string. The function is intended to show the elapsed time from a user perspective (sitting with a stopwatch in front of the computer). timesince relies on Python's timezone arithmetic, however, there are subtle implementation details for intra- and inter-timezone calculations. See
- Discussion about the topic: https://github.com/python/cpython/issues/116111
- Quiz to illustrate subtle examples: https://quiz.sauerburger.com/dxi7m/
Consider the following example around the daylight saving time transition next weekend in Europe. We start at a point in time a, ten minutes later we have a_10, and another 60 minutes later we have a_70.
from zoneinfo import ZoneInfo from datetime import datetime from django.utils.timesince import timesince berlin = ZoneInfo("Europe/Berlin") a = datetime(2024, 10, 27, 2, 55, tzinfo=berlin) a_10 = datetime(2024, 10, 27, 2, 5, fold=1, tzinfo=berlin) a_70 = datetime(2024, 10, 27, 3, 5, tzinfo=berlin) assert a.isoformat() == '2024-10-27T02:55:00+02:00' assert a_10.isoformat() == '2024-10-27T02:05:00+01:00' assert a_70.isoformat() == '2024-10-27T03:05:00+01:00' assert timesince(a, a_10) == '0\xa0minutes' assert timesince(a, a_70) == '10\xa0minutes'
My expectation is that timesince(a, a_10) yields 10 minutes and timesince(a, a_70) yields 70 minutes aligned with what a user with a stopwatch would observe.
I think this can lead to a lot of unexpected behavior in web applications around the DST transition and maybe even exploitable behavior.
Change History (4)
comment:1 by , 3 weeks ago
Component: | Uncategorized → Utilities |
---|---|
Triage Stage: | Unreviewed → Accepted |
Type: | Uncategorized → Bug |
comment:2 by , 3 weeks ago
Hi Sarah,
yes that's also a good way to demonstrate the issue. In my example, I also wanted to demonstrate that timesince
can confuse the order of events. a_10
happens 10 minutes after event a
, but timesince
returns '0 minutes' as if the order was reversed.
EDIT: Actually, if I'm not mistaken, in your example, there is no difference between
a_10 = datetime(2024, 10, 27, 3, 5, fold=0, tzinfo=berlin) a_70 = datetime(2024, 10, 27, 3, 5, fold=1, tzinfo=berlin)
The overlapping folds are between 2 and 3 am.
comment:4 by , 42 hours ago
hey,
I've implemented several changes to improve handling of timezone-aware datetimes, particularly around DST transitions. Key improvements include ensuring naive datetimes are converted to aware ones using make_aware, normalizing timezone info for both d and now, and adjusting the comparison logic to account for DST transitions. Could you please review the code and let me know if there are any further improvements or edge cases that I might have missed?
Your feedback would be greatly appreciated!
# If d is naive (has no timezone info), make it aware using the current time zone if d.tzinfo is None: d = make_aware(d) # Convert naive datetime to aware if now and now.tzinfo is None: # If now is naive, make it aware now = make_aware(now) # Compared datetimes must be in the same time zone. if not now: now = datetime.datetime.now(d.tzinfo if is_aware(d) else None) elif is_aware(now) and is_aware(d): now = now.astimezone(d.tzinfo) if reversed: d, now = now, d if d.tzinfo is not None and now.tzinfo is not None: delta = now.astimezone(d.tzinfo) - d else: delta = now - d # Normalize times to handle DST transitions if d.tzinfo is not None: d = d.tzinfo.normalize(d) if now.tzinfo is not None: now = now.tzinfo.normalize(now)
remaining_time = (now.astimezone(d.tzinfo) - pivot).total_seconds()
If I understood correctly, I think the example was meant to show:
For those not familiar with fold, this is used to disambiguate wall times during a repeated interval. The values 0 and 1 represent, respectively, the earlier and later of the two moments with the same wall time representation.
Looking at the discussion, it appears timesince should convert the dates to UTC before subtracting them.
Linking #34483 as this is slightly related