X-Git-Url: https://wannabe.guru.org/gitweb/?a=blobdiff_plain;f=datetime_utils.py;h=55e0ffab3b0dcedf1e6830aef38cd387d736fde0;hb=e46158e49121b8a955bb07b73f5bcf9928b79c90;hp=3565936fce66c1197a04a8926f902452e6350ac4;hpb=e6f32fdd9b373dfcd100c7accb41f57d83c2f0a1;p=python_utils.git diff --git a/datetime_utils.py b/datetime_utils.py index 3565936..55e0ffa 100644 --- a/datetime_utils.py +++ b/datetime_utils.py @@ -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) @@ -104,7 +217,7 @@ def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.d 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 +272,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 +301,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 +317,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 +329,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) @@ -229,7 +340,7 @@ def datetime_to_time(dt: datetime.datetime) -> datetime.time: return datetime_to_date_and_time(dt)[1] -class TimeUnit(enum.Enum): +class TimeUnit(enum.IntEnum): """An enum to represent units with which we can compute deltas.""" MONDAYS = 0 @@ -250,20 +361,18 @@ class TimeUnit(enum.Enum): @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... @@ -304,6 +413,17 @@ def n_timeunits_from_base( >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base) datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))) + Next month corner case -- it will try to make Feb 31, 2022 then count + backwards. + >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0] + >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base) + datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))) + + Last month with the same corner case + >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0] + >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base) + datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))) + """ assert TimeUnit.is_valid(unit) if count == 0: @@ -342,10 +462,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 @@ -364,16 +481,23 @@ def n_timeunits_from_base( new_month %= 12 year_term += 1 new_year = base.year + year_term - return datetime.datetime( - new_year, - new_month, - base.day, - base.hour, - base.minute, - base.second, - base.microsecond, - base.tzinfo, - ) + day = base.day + while True: + try: + ret = datetime.datetime( + new_year, + new_month, + day, + base.hour, + base.minute, + base.second, + base.microsecond, + base.tzinfo, + ) + break + except ValueError: + day -= 1 + return ret # N years from base elif unit == TimeUnit.YEARS: @@ -497,6 +621,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, @@ -530,6 +655,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, @@ -762,7 +888,7 @@ def describe_timedelta(delta: datetime.timedelta) -> str: '1 day, and 10 minutes' """ - return describe_duration(delta.total_seconds()) + return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str: @@ -807,7 +933,7 @@ def describe_timedelta_briefly(delta: datetime.timedelta) -> str: '1d 10m' """ - return describe_duration_briefly(delta.total_seconds()) + return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds if __name__ == '__main__':