#!/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
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
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:
"""
- 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.
+ 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
+
+ """
+
+ # 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()
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'
>>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
>>> t.tzname()
'US/Pacific'
-
"""
return t.replace(tzinfo=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()
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.
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)
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)
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)
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
@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...
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
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,
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,
'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:
'1d 10m'
"""
- return describe_duration_briefly(delta.total_seconds())
+ return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
if __name__ == '__main__':