How to check if a datetime value is valid (and not in time gap caused by DST change)?

76 Views Asked by At

Daylight Saving Time (DST) changes occur usually twice a year. During fall clocks are moved backwards which creates a fold which means that a given local time has two possible meanings. This is covered in PEP-495. During spring clocks are moved forwards which creates a gap in possible local time values, typically with length of one hour.

For example in "Europe/London" timezone the possible minutes at 2023-03-26 near the DST change are: 00:58, 00:59, 02:00, 02:01; The values 01:00 to 01:59 do not exist.

Example

Here is an example of cases which should be detected as valid (possible) and impossible:

import datetime as dt 
from zoneinfo import ZoneInfo 

timestamp_possible = dt.datetime.strptime(
        "2023-03-26 00:30:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))

timestamp_impossible = dt.datetime.strptime(
        "2023-03-26 01:30:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))

Here is how I could do it using pandas.to_datetime, but I'm not entirely sure why it works and if that is an implementation detail of pandas (which might change?) or guaranteed behaviour.

def is_valid(timestamp):
    return pd.to_datetime(timestamp).to_pydatetime(timestamp) == timestamp

which gives

>>> is_valid(timestamp_possible)
True
>>> is_valid(timestamp_impossible)
False

Question

What would be the simplest possible way to detect if a given datetime.datetime object is valid or hitting the "nonexistent time values" gap? Is there some way to do this in Python standard library?

1

There are 1 best solutions below

0
Niko Fohr On

I took a closer look on the PEP-495 and there's actually a pretty useful function down in the Strict Invalid Time Checking section:

def utcoffset(dt, raise_on_gap=True, raise_on_fold=False):
    u = dt.utcoffset()
    v = dt.replace(fold=not dt.fold).utcoffset()
    if u == v:
        return u
    if (u < v) == dt.fold:
        if raise_on_fold:
            raise AmbiguousTimeError
    else:
        if raise_on_gap:
            raise MissingTimeError
    return u

It is pretty easy to modify that to make it return a flag which tells if the timestamp is at the gap:

import enum

class DstFlag(enum.IntEnum):
    NONE = 0
    FOLD = 1
    GAP = 2
    
def get_dst_flag(timestamp: dt.datetime) -> DstFlag:
    u = timestamp.utcoffset()
    v = timestamp.replace(fold=not timestamp.fold).utcoffset()
    if u == v:
       return DstFlag.NONE 
    if (u < v) == timestamp.fold:
       return DstFlag.FOLD
    return DstFlag.GAP
    

The (u < v) == timestamp.fold does not look very understandable but anyway it looks like it's working well. Some test values:

# Spring DST Change 2023-03-26 01:00 UTC+0 (01:00 local time)
# 01:00 (UTC+) -> 02:00 (UTC+1)
dt_normal1 = dt.datetime.strptime(
        "2023-03-26 00:00:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))
    
dt_withgap = dt.datetime.strptime(
        "2023-03-26 01:30:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))
    
dt_normal2 = dt.datetime.strptime(
        "2023-03-26 03:00:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))
    

# Fall DST Change 2023-10-29 01:00 UTC+0 (02:00 local time)
# 02:00 (UTC+1) -> 01:00 (UTC+0)
dt_normal3 = dt.datetime.strptime(
        "2023-10-29 00:00:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))
    
dt_withfold = dt.datetime.strptime(
        "2023-10-29 01:30:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))
    
dt_normal4 = dt.datetime.strptime(
        "2023-10-29 03:00:00", "%Y-%m-%d %H:%M:%S"
    ).replace(tzinfo=ZoneInfo("Europe/London"))

and test return values:

>>> get_dst_flag(dt_normal1)
<DstFlag.NONE: 0>
>>> get_dst_flag(dt_withgap)
<DstFlag.GAP: 2>
>>> get_dst_flag(dt_normal2)
<DstFlag.NONE: 0>
>>> get_dst_flag(dt_normal3)
<DstFlag.NONE: 0>
>>> get_dst_flag(dt_withfold)
<DstFlag.FOLD: 1>
>>> get_dst_flag(dt_normal4)
<DstFlag.NONE: 0>