Since this thing is on the innerwebs I suppose it should have a
[python_utils.git] / datetime_utils.py
index 1cee5163a22179d3c634a078433e83c53add8109..b05097a909a53a1a39836dffbd91e02887b8aabd 100644 (file)
@@ -1,12 +1,14 @@
 #!/usr/bin/env python3
 
 #!/usr/bin/env python3
 
+# © Copyright 2021-2022, Scott Gasch
+
 """Utilities related to dates and times and datetimes."""
 
 import datetime
 import enum
 import logging
 import re
 """Utilities related to dates and times and datetimes."""
 
 import datetime
 import enum
 import logging
 import re
-from typing import Any, NewType, Tuple
+from typing import Any, NewType, Optional, Tuple
 
 import holidays  # type: ignore
 import pytz
 
 import holidays  # type: ignore
 import pytz
@@ -31,13 +33,92 @@ def is_timezone_aware(dt: datetime.datetime) -> bool:
 
 
 def is_timezone_naive(dt: datetime.datetime) -> bool:
 
 
 def is_timezone_naive(dt: datetime.datetime) -> bool:
+    """Inverse of is_timezone_aware.
+
+    >>> is_timezone_naive(datetime.datetime.now())
+    True
+
+    >>> is_timezone_naive(now_pacific())
+    False
+
+    """
     return not is_timezone_aware(dt)
 
 
     return not is_timezone_aware(dt)
 
 
-def replace_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
+def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
+    """Remove the timezone from a datetime.  Does not change the
+    hours, minutes, seconds, months, days, years, etc... Thus the
+    instant to which this timestamp refers will change.  Silently
+    ignores datetimes which are already timezone naive.
+
+    >>> now = now_pacific()
+    >>> now.tzinfo == None
+    False
+
+    >>> dt = strip_timezone(now)
+    >>> dt == now
+    False
+
+    >>> dt.tzinfo == None
+    True
+
+    >>> dt.hour == now.hour
+    True
+
+    """
+    if is_timezone_naive(dt):
+        return dt
+    return replace_timezone(dt, None)
+
+
+def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
+    """
+    Adds a timezone to a timezone naive datetime.  This does not
+    change the instant to which the timestamp refers.  See also:
+    replace_timezone.
+
+    >>> now = datetime.datetime.now()
+    >>> is_timezone_aware(now)
+    False
+
+    >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
+    >>> is_timezone_aware(now_pacific)
+    True
+
+    >>> now.hour == now_pacific.hour
+    True
+    >>> now.minute == now_pacific.minute
+    True
+
     """
     """
-    Replaces the timezone on a datetime object directly (leaving
-    the year, month, day, hour, minute, second, micro, etc... alone).
+
+    # This doesn't work, tz requires a timezone naive dt.  Two options
+    # here:
+    #     1. Use strip_timezone and try again.
+    #     2. Replace the timezone on your dt object via replace_timezone.
+    #        Be aware that this changes the instant to which the dt refers
+    #        and, further, can introduce weirdness like UTC offsets that
+    #        are weird (e.g. not an even multiple of an hour, etc...)
+    if is_timezone_aware(dt):
+        if dt.tzinfo == tz:
+            return dt
+        raise Exception(
+            f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
+            + 'depending on the semantics you want.'
+        )
+    return dt.replace(tzinfo=tz)
+
+
+def replace_timezone(dt: datetime.datetime, tz: Optional[datetime.tzinfo]) -> datetime.datetime:
+    """Replaces the timezone on a timezone aware datetime object directly
+    (leaving the year, month, day, hour, minute, second, micro,
+    etc... alone).
+
+    Works with timezone aware and timezone naive dts but for the
+    latter it is probably better to use add_timezone or just create it
+    with a tz parameter.  Using this can have weird side effects like
+    UTC offsets that are not an even multiple of an hour, etc...
+
     Note: this changes the instant to which this dt refers.
 
     >>> from pytz import UTC
     Note: this changes the instant to which this dt refers.
 
     >>> from pytz import UTC
@@ -52,22 +133,33 @@ def replace_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.dat
     True
 
     """
     True
 
     """
-    return datetime.datetime(
-        dt.year,
-        dt.month,
-        dt.day,
-        dt.hour,
-        dt.minute,
-        dt.second,
-        dt.microsecond,
-        tzinfo=tz,
-    )
+    if is_timezone_aware(dt):
+        logger.warning(
+            '%s already has a timezone; klobbering it anyway.\n  Be aware that this operation changed the instant to which the object refers.',
+            dt,
+        )
+        return datetime.datetime(
+            dt.year,
+            dt.month,
+            dt.day,
+            dt.hour,
+            dt.minute,
+            dt.second,
+            dt.microsecond,
+            tzinfo=tz,
+        )
+    else:
+        if tz:
+            return add_timezone(dt, tz)
+        else:
+            return dt
 
 
 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
     """
     Replaces the timezone on a datetime.time directly without performing
 
 
 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
     """
     Replaces the timezone on a datetime.time directly without performing
-    any translation.
+    any translation.  Note that, as above, this will change the instant
+    to which the time refers.
 
     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
     >>> t.tzname()
 
     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
     >>> t.tzname()
@@ -104,7 +196,7 @@ def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.d
 
 def now() -> datetime.datetime:
     """
 
 def now() -> datetime.datetime:
     """
-    What time is it?  Result returned in UTC
+    What time is it?  Returned as a timezone naive datetime.
     """
     return datetime.datetime.now()
 
     """
     return datetime.datetime.now()
 
@@ -159,9 +251,8 @@ def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
     False
 
     """
     False
 
     """
-    now = now_pacific()
     tz = time.tzinfo
     tz = time.tzinfo
-    return datetime.datetime.combine(now, time, tz)
+    return datetime.datetime.combine(now_pacific(), time, tz)
 
 
 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
 
 
 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
@@ -248,12 +339,12 @@ class TimeUnit(enum.IntEnum):
 
     @classmethod
     def is_valid(cls, value: Any):
 
     @classmethod
     def is_valid(cls, value: Any):
-        if type(value) is int:
-            return value in cls._value2member_map_
-        elif type(value) is TimeUnit:
-            return value.value in cls._value2member_map_
-        elif type(value) is str:
-            return value in cls._member_names_
+        if isinstance(value, int):
+            return cls(value) is not None
+        elif isinstance(value, TimeUnit):
+            return cls(value.value) is not None
+        elif isinstance(value, str):
+            return cls.__members__[value] is not None
         else:
             print(type(value))
             return False
         else:
             print(type(value))
             return False
@@ -508,6 +599,7 @@ def datetime_to_string(
         date_time_separator=date_time_separator,
         include_timezone=include_timezone,
         include_dayname=include_dayname,
         date_time_separator=date_time_separator,
         include_timezone=include_timezone,
         include_dayname=include_dayname,
+        use_month_abbrevs=use_month_abbrevs,
         include_seconds=include_seconds,
         include_fractional=include_fractional,
         twelve_hour=twelve_hour,
         include_seconds=include_seconds,
         include_fractional=include_fractional,
         twelve_hour=twelve_hour,
@@ -541,6 +633,7 @@ def string_to_datetime(
         date_time_separator=date_time_separator,
         include_timezone=include_timezone,
         include_dayname=include_dayname,
         date_time_separator=date_time_separator,
         include_timezone=include_timezone,
         include_dayname=include_dayname,
+        use_month_abbrevs=use_month_abbrevs,
         include_seconds=include_seconds,
         include_fractional=include_fractional,
         twelve_hour=twelve_hour,
         include_seconds=include_seconds,
         include_fractional=include_fractional,
         twelve_hour=twelve_hour,