ariebovenberg / whenever

⏰ Modern datetime library for Python
https://whenever.rtfd.io
MIT License
898 stars 15 forks source link

Unexpected behavior of disambiguate during dst change foreward #150

Closed spacemanspiff2007 closed 4 months ago

spacemanspiff2007 commented 4 months ago

When the dst change means that the clock moves forward disambiguate behaves rather unexpected. I would have expected that earlier returns the time before the switch forward and later returns the time when the switch is complete. However whenever is guessing that I might want to add/subtract an hour and does that for me resulting in the following behavior (Which is in brief described here)

# Clock moves from 2 -> 3, so this does not exist
dt = ZonedDateTime(2001, 3, 25, 2, 30, tz='Europe/Berlin', disambiguate='earlier')
print(dt)
dt = ZonedDateTime(2001, 3, 25, 2, 30, tz='Europe/Berlin', disambiguate='later')
print(dt)
2001-03-25T01:30:00+01:00[Europe/Berlin]
2001-03-25T03:30:00+02:00[Europe/Berlin]

What I would have expected

2001-03-25T01:59:59.999...+01:00[Europe/Berlin]
2001-03-25T03:00:00+02:00[Europe/Berlin]

Could you explain the reasoning behind this behavior?

ariebovenberg commented 4 months ago

Happy to explain. This probably warrants a note in the docs.

The reasons for this behavior:

The figure in the Python docs here also shows how this ‘extrapolation’ makes sense graphically.

edit: PS: When first developing the library, I was surprised by this behavior as well. However, I soon discovered the reasoning behind it. There's an interesting thread here where the Temporal designers discuss the matter.

spacemanspiff2007 commented 4 months ago

Thank you for the insight. What do you think is the best way to find the time after the DST switch? I've come up with something like this:

def find_time_after_dst_switch(dt: SystemDateTime, time: Time) -> Instant:
    # DST changes typically occur on the full minute
    time = time.replace(second=0, nanosecond=0)
    hour = time.hour
    minute = time.minute

    while True:
        minute += 1
        if minute >= 60:
            minute = 0
            hour += 1
            if hour >= 24:
                hour = 0
        time = time.replace(hour=hour, minute=minute)
        try:
            return dt.replace_time(time.replace(hour=hour, minute=minute), disambiguate='raise').instant()
        except SkippedTime:
            continue
ariebovenberg commented 4 months ago

@spacemanspiff2007 there's no clean way to do this, I'm afraid. This information would need to be exposed by ZoneInfo classes. I'll probably submit a feature request for this in the stdlib.

Some potential improvements to your code:

Here is a naive algorithm which can be improved by bisect, and probably has some bugs in it still...

skipped_time = SystemDateTime(...)

guess = skipped_time.local().subtract(hours=48, ignore_dst=True)  # assume there are no two transitions so closeby
while True:
    try:
        guess.add(minutes=1, ignore_dst=True).assume_system_tz(disambiguate="raise")
    except SkippedTime:
        break
ariebovenberg commented 4 months ago

@spacemanspiff2007 I've added an explicit note to the docs in the section about ambiguity. Let me know if you have any other ideas/suggestions.