Easier and more self documenting patterns for loading/saving Persistent
[python_utils.git] / datetime_utils.py
index fb859719796c5cc377e328bacf2aecf2b6bb81f6..10b166605570b14332fe7c7f5ebf74b645a05960 100644 (file)
@@ -1,12 +1,14 @@
 #!/usr/bin/env python3
 
-"""Utilities related to dates and times and datetimes."""
+# © Copyright 2021-2022, Scott Gasch
+
+"""Utilities related to dates, 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
@@ -17,8 +19,14 @@ logger = logging.getLogger(__name__)
 
 
 def is_timezone_aware(dt: datetime.datetime) -> bool:
-    """See: https://docs.python.org/3/library/datetime.html
-                               #determining-if-an-object-is-aware-or-naive
+    """Returns true if the datetime argument is timezone aware or
+    False if not.
+
+    See: https://docs.python.org/3/library/datetime.html
+    #determining-if-an-object-is-aware-or-naive
+
+    Args:
+        dt: The datetime object to check
 
     >>> is_timezone_aware(datetime.datetime.now())
     False
@@ -31,14 +39,106 @@ def is_timezone_aware(dt: datetime.datetime) -> bool:
 
 
 def is_timezone_naive(dt: datetime.datetime) -> bool:
+    """Inverse of is_timezone_aware -- returns true if the dt argument
+    is timezone naive.
+
+    See: https://docs.python.org/3/library/datetime.html
+    #determining-if-an-object-is-aware-or-naive
+
+    Args:
+        dt: The datetime object to check
+
+    >>> is_timezone_naive(datetime.datetime.now())
+    True
+
+    >>> is_timezone_naive(now_pacific())
+    False
+
+    """
     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.
+
+    .. warning::
+
+        This 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).
-    Note: this changes the instant to which this dt refers.
+
+    # 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.  See the pydocs / code.'
+        )
+    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...
+
+    .. warning::
+
+        This changes the instant to which this dt refers.
 
     >>> from pytz import UTC
     >>> d = now_pacific()
@@ -52,23 +152,37 @@ def replace_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.dat
     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
+    """Replaces the timezone on a datetime.time directly without performing
     any translation.
 
+    .. warning::
+
+        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()
     'UTC'
@@ -76,7 +190,6 @@ def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.tim
     >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
     >>> t.tzname()
     'US/Pacific'
-
     """
     return t.replace(tzinfo=tz)
 
@@ -87,24 +200,27 @@ def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.d
     day, hour, minute, second, micro, etc... appropriately.  The returned
     dt is the same instant in another timezone.
 
-    >>> from pytz import UTC
+    >>> import pytz
     >>> d = now_pacific()
     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
     'P'
     >>> h = d.hour
-    >>> o = translate_timezone(d, UTC)
-    >>> o.tzinfo.tzname(o)
-    'UTC'
+    >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
+    >>> o.tzinfo.tzname(o)[0]     # Again, could be EST or EDT
+    'E'
     >>> o.hour == h
     False
-
+    >>> expected = h + 3          # Three hours later in E?T than P?T
+    >>> expected = expected % 24  # Handle edge case
+    >>> expected == o.hour
+    True
     """
-    return dt.replace(tzinfo=None).astimezone(tz=tz)
+    return dt.replace().astimezone(tz=tz)
 
 
 def now() -> datetime.datetime:
     """
-    What time is it?  Result returned in UTC
+    What time is it?  Result is a timezone naive datetime.
     """
     return datetime.datetime.now()
 
@@ -159,14 +275,11 @@ def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
     False
 
     """
-    now = now_pacific()
     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:
     """
     Given a date and time, merge them and return a datetime.
 
@@ -191,7 +304,8 @@ def date_and_time_to_datetime(
 def datetime_to_date_and_time(
     dt: datetime.datetime,
 ) -> Tuple[datetime.date, datetime.time]:
-    """Return the component date and time objects of a datetime.
+    """Return the component date and time objects of a datetime in a
+    Tuple given a datetime.
 
     >>> import datetime
     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
@@ -206,7 +320,7 @@ def datetime_to_date_and_time(
 
 
 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
-    """Return the date part of a datetime.
+    """Return just the date part of a datetime.
 
     >>> import datetime
     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
@@ -218,7 +332,7 @@ def datetime_to_date(dt: datetime.datetime) -> datetime.date:
 
 
 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
-    """Return the time part of a datetime.
+    """Return just the time part of a datetime.
 
     >>> import datetime
     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
@@ -250,20 +364,18 @@ class TimeUnit(enum.IntEnum):
 
     @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
 
 
-def n_timeunits_from_base(
-    count: int, unit: TimeUnit, base: datetime.datetime
-) -> datetime.datetime:
+def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
     """Return a datetime that is N units before/after a base datetime.
     e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
     years before base datetime, 13 minutes after base datetime, etc...
@@ -353,10 +465,7 @@ def n_timeunits_from_base(
             base += timedelta
             if base.year != old_year:
                 skips = holidays.US(years=base.year).keys()
-            if (
-                base.weekday() < 5
-                and datetime.date(base.year, base.month, base.day) not in skips
-            ):
+            if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
                 count -= 1
         return base
 
@@ -515,6 +624,7 @@ def datetime_to_string(
         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,
@@ -548,6 +658,7 @@ def string_to_datetime(
         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,
@@ -825,9 +936,7 @@ def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
     '1d 10m'
 
     """
-    return describe_duration_briefly(
-        int(delta.total_seconds())
-    )  # Note: drops milliseconds
+    return describe_duration_briefly(int(delta.total_seconds()))  # Note: drops milliseconds
 
 
 if __name__ == '__main__':