Opened 3 weeks ago

Last modified 2 days 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

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 Sarah Boyce, 3 weeks ago

Component: UncategorizedUtilities
Triage Stage: UnreviewedAccepted
Type: UncategorizedBug

If I understood correctly, I think the example was meant to show:

a = datetime(2024, 10, 27, 2, 55, tzinfo=berlin)
a_10 = datetime(2024, 10, 27, 3, 5, fold=0, tzinfo=berlin)
a_70 = datetime(2024, 10, 27, 3, 5, fold=1, tzinfo=berlin)
assert timesince(a, a_10) == '10\xa0minutes'
assert timesince(a, a_70) == '10\xa0minutes'  # expected 1 hour 10 minutes

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

comment:2 by Frank Sauerburger, 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.

Version 0, edited 3 weeks ago by Frank Sauerburger (next)

comment:3 by Sarah Boyce, 3 weeks ago

Ah I see, sorry Frank I misunderstood your example

comment:4 by rohan yadav, 2 days 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()
Note: See TracTickets for help on using tickets.
Back to Top