3 # © Copyright 2021-2022, Scott Gasch
5 """Utilities related to dates and times and datetimes."""
11 from typing import Any, NewType, Optional, Tuple
13 import holidays # type: ignore
18 logger = logging.getLogger(__name__)
21 def is_timezone_aware(dt: datetime.datetime) -> bool:
22 """See: https://docs.python.org/3/library/datetime.html
23 #determining-if-an-object-is-aware-or-naive
25 >>> is_timezone_aware(datetime.datetime.now())
28 >>> is_timezone_aware(now_pacific())
32 return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
35 def is_timezone_naive(dt: datetime.datetime) -> bool:
36 """Inverse of is_timezone_aware.
38 >>> is_timezone_naive(datetime.datetime.now())
41 >>> is_timezone_naive(now_pacific())
45 return not is_timezone_aware(dt)
48 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
49 """Remove the timezone from a datetime. Does not change the
50 hours, minutes, seconds, months, days, years, etc... Thus the
51 instant to which this timestamp refers will change. Silently
52 ignores datetimes which are already timezone naive.
54 >>> now = now_pacific()
55 >>> now.tzinfo == None
58 >>> dt = strip_timezone(now)
65 >>> dt.hour == now.hour
69 if is_timezone_naive(dt):
71 return replace_timezone(dt, None)
74 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
76 Adds a timezone to a timezone naive datetime. This does not
77 change the instant to which the timestamp refers. See also:
80 >>> now = datetime.datetime.now()
81 >>> is_timezone_aware(now)
84 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
85 >>> is_timezone_aware(now_pacific)
88 >>> now.hour == now_pacific.hour
90 >>> now.minute == now_pacific.minute
95 # This doesn't work, tz requires a timezone naive dt. Two options
97 # 1. Use strip_timezone and try again.
98 # 2. Replace the timezone on your dt object via replace_timezone.
99 # Be aware that this changes the instant to which the dt refers
100 # and, further, can introduce weirdness like UTC offsets that
101 # are weird (e.g. not an even multiple of an hour, etc...)
102 if is_timezone_aware(dt):
106 f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
107 + 'depending on the semantics you want.'
109 return dt.replace(tzinfo=tz)
112 def replace_timezone(dt: datetime.datetime, tz: Optional[datetime.tzinfo]) -> datetime.datetime:
113 """Replaces the timezone on a timezone aware datetime object directly
114 (leaving the year, month, day, hour, minute, second, micro,
117 Works with timezone aware and timezone naive dts but for the
118 latter it is probably better to use add_timezone or just create it
119 with a tz parameter. Using this can have weird side effects like
120 UTC offsets that are not an even multiple of an hour, etc...
122 Note: this changes the instant to which this dt refers.
124 >>> from pytz import UTC
125 >>> d = now_pacific()
126 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
129 >>> o = replace_timezone(d, UTC)
130 >>> o.tzinfo.tzname(o)
136 if is_timezone_aware(dt):
138 '%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.',
141 return datetime.datetime(
153 return add_timezone(dt, tz)
158 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
160 Replaces the timezone on a datetime.time directly without performing
161 any translation. Note that, as above, this will change the instant
162 to which the time refers.
164 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
168 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
173 return t.replace(tzinfo=tz)
176 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
178 Translates dt into a different timezone by adjusting the year, month,
179 day, hour, minute, second, micro, etc... appropriately. The returned
180 dt is the same instant in another timezone.
182 >>> from pytz import UTC
183 >>> d = now_pacific()
184 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
187 >>> o = translate_timezone(d, UTC)
188 >>> o.tzinfo.tzname(o)
194 return dt.replace(tzinfo=None).astimezone(tz=tz)
197 def now() -> datetime.datetime:
199 What time is it? Returned as a timezone naive datetime.
201 return datetime.datetime.now()
204 def now_pacific() -> datetime.datetime:
206 What time is it? Result in US/Pacific time (PST/PDT)
208 return datetime.datetime.now(pytz.timezone("US/Pacific"))
211 def date_to_datetime(date: datetime.date) -> datetime.datetime:
213 Given a date, return a datetime with hour/min/sec zero (midnight)
216 >>> date_to_datetime(datetime.date(2021, 12, 25))
217 datetime.datetime(2021, 12, 25, 0, 0)
220 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
223 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
225 Given a time, returns that time as a datetime with a date component
226 set based on the current date. If the time passed is timezone aware,
227 the resulting datetime will also be (and will use the same tzinfo).
228 If the time is timezone naive, the datetime returned will be too.
230 >>> t = datetime.time(13, 14, 0)
231 >>> d = now_pacific().date()
232 >>> dt = time_to_datetime_today(t)
239 >>> dt.tzinfo == t.tzinfo
242 >>> dt.tzinfo == None
245 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
249 >>> dt = time_to_datetime_today(t)
250 >>> dt.tzinfo == None
255 return datetime.datetime.combine(now_pacific(), time, tz)
258 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
260 Given a date and time, merge them and return a datetime.
263 >>> d = datetime.date(2021, 12, 25)
264 >>> t = datetime.time(12, 30, 0, 0)
265 >>> date_and_time_to_datetime(d, t)
266 datetime.datetime(2021, 12, 25, 12, 30)
269 return datetime.datetime(
280 def datetime_to_date_and_time(
281 dt: datetime.datetime,
282 ) -> Tuple[datetime.date, datetime.time]:
283 """Return the component date and time objects of a datetime.
286 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
287 >>> (d, t) = datetime_to_date_and_time(dt)
289 datetime.date(2021, 12, 25)
291 datetime.time(12, 30)
294 return (dt.date(), dt.timetz())
297 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
298 """Return the date part of a datetime.
301 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
302 >>> datetime_to_date(dt)
303 datetime.date(2021, 12, 25)
306 return datetime_to_date_and_time(dt)[0]
309 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
310 """Return the time part of a datetime.
313 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
314 >>> datetime_to_time(dt)
315 datetime.time(12, 30)
318 return datetime_to_date_and_time(dt)[1]
321 class TimeUnit(enum.IntEnum):
322 """An enum to represent units with which we can compute deltas."""
341 def is_valid(cls, value: Any):
342 if isinstance(value, int):
343 return cls(value) is not None
344 elif isinstance(value, TimeUnit):
345 return cls(value.value) is not None
346 elif isinstance(value, str):
347 return cls.__members__[value] is not None
353 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
354 """Return a datetime that is N units before/after a base datetime.
355 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
356 years before base datetime, 13 minutes after base datetime, etc...
357 Note: to indicate before/after the base date, use a positive or
360 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
362 The next (1) Monday from the base datetime:
363 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
364 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
366 Ten (10) years after the base datetime:
367 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
368 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
370 Fifty (50) working days (M..F, not counting holidays) after base datetime:
371 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
372 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
374 Fifty (50) days (including weekends and holidays) after base datetime:
375 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
376 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
378 Fifty (50) months before (note negative count) base datetime:
379 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
380 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
382 Fifty (50) hours after base datetime:
383 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
384 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
386 Fifty (50) minutes before base datetime:
387 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
388 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
390 Fifty (50) seconds from base datetime:
391 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
392 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
394 Next month corner case -- it will try to make Feb 31, 2022 then count
396 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
397 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
398 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
400 Last month with the same corner case
401 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
402 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
403 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
406 assert TimeUnit.is_valid(unit)
411 if unit == TimeUnit.DAYS:
412 timedelta = datetime.timedelta(days=count)
413 return base + timedelta
416 elif unit == TimeUnit.HOURS:
417 timedelta = datetime.timedelta(hours=count)
418 return base + timedelta
420 # N minutes from base
421 elif unit == TimeUnit.MINUTES:
422 timedelta = datetime.timedelta(minutes=count)
423 return base + timedelta
425 # N seconds from base
426 elif unit == TimeUnit.SECONDS:
427 timedelta = datetime.timedelta(seconds=count)
428 return base + timedelta
430 # N workdays from base
431 elif unit == TimeUnit.WORKDAYS:
434 timedelta = datetime.timedelta(days=-1)
436 timedelta = datetime.timedelta(days=1)
437 skips = holidays.US(years=base.year).keys()
441 if base.year != old_year:
442 skips = holidays.US(years=base.year).keys()
443 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
448 elif unit == TimeUnit.WEEKS:
449 timedelta = datetime.timedelta(weeks=count)
450 base = base + timedelta
454 elif unit == TimeUnit.MONTHS:
455 month_term = count % 12
456 year_term = count // 12
457 new_month = base.month + month_term
461 new_year = base.year + year_term
465 ret = datetime.datetime(
481 elif unit == TimeUnit.YEARS:
482 new_year = base.year + count
483 return datetime.datetime(
505 raise ValueError(unit)
507 # N weekdays from base (e.g. 4 wednesdays from today)
508 direction = 1 if count > 0 else -1
510 timedelta = datetime.timedelta(days=direction)
514 if dow == unit.value and start != base:
518 base = base + timedelta
521 def get_format_string(
523 date_time_separator=" ",
524 include_timezone=True,
525 include_dayname=False,
526 use_month_abbrevs=False,
527 include_seconds=True,
528 include_fractional=False,
532 Helper to return a format string without looking up the documentation
535 >>> get_format_string()
536 '%Y/%m/%d %I:%M:%S%p%z'
538 >>> get_format_string(date_time_separator='@')
539 '%Y/%m/%d@%I:%M:%S%p%z'
541 >>> get_format_string(include_dayname=True)
542 '%a/%Y/%m/%d %I:%M:%S%p%z'
544 >>> get_format_string(include_dayname=True, twelve_hour=False)
545 '%a/%Y/%m/%d %H:%M:%S%z'
552 if use_month_abbrevs:
553 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
555 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
565 if include_fractional:
572 def datetime_to_string(
573 dt: datetime.datetime,
575 date_time_separator=" ",
576 include_timezone=True,
577 include_dayname=False,
578 use_month_abbrevs=False,
579 include_seconds=True,
580 include_fractional=False,
584 A nice way to convert a datetime into a string; arguably better than
585 just printing it and relying on it __repr__().
587 >>> d = string_to_datetime(
588 ... "2021/09/10 11:24:51AM-0700",
591 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
592 >>> datetime_to_string(d)
593 '2021/09/10 11:24:51AM-0700'
594 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
595 'Fri/2021/09/10 11:24AM-0700'
598 fstring = get_format_string(
599 date_time_separator=date_time_separator,
600 include_timezone=include_timezone,
601 include_dayname=include_dayname,
602 use_month_abbrevs=use_month_abbrevs,
603 include_seconds=include_seconds,
604 include_fractional=include_fractional,
605 twelve_hour=twelve_hour,
607 return dt.strftime(fstring).strip()
610 def string_to_datetime(
613 date_time_separator=" ",
614 include_timezone=True,
615 include_dayname=False,
616 use_month_abbrevs=False,
617 include_seconds=True,
618 include_fractional=False,
620 ) -> Tuple[datetime.datetime, str]:
621 """A nice way to convert a string into a datetime. Returns both the
622 datetime and the format string used to parse it. Also consider
623 dateparse.dateparse_utils for a full parser alternative.
625 >>> d = string_to_datetime(
626 ... "2021/09/10 11:24:51AM-0700",
629 (datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))), '%Y/%m/%d %I:%M:%S%p%z')
632 fstring = get_format_string(
633 date_time_separator=date_time_separator,
634 include_timezone=include_timezone,
635 include_dayname=include_dayname,
636 use_month_abbrevs=use_month_abbrevs,
637 include_seconds=include_seconds,
638 include_fractional=include_fractional,
639 twelve_hour=twelve_hour,
641 return (datetime.datetime.strptime(txt, fstring), fstring)
644 def timestamp() -> str:
645 """Return a timestamp for right now in Pacific timezone."""
646 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
647 return datetime_to_string(ts, include_timezone=True)
651 dt: datetime.datetime,
653 include_seconds=True,
654 include_fractional=False,
655 include_timezone=False,
658 """A nice way to convert a datetime into a time (only) string.
659 This ignores the date part of the datetime.
661 >>> d = string_to_datetime(
662 ... "2021/09/10 11:24:51AM-0700",
665 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
667 >>> time_to_string(d)
670 >>> time_to_string(d, include_seconds=False)
673 >>> time_to_string(d, include_seconds=False, include_timezone=True)
687 if include_fractional:
691 return dt.strftime(fstring).strip()
694 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
695 """Convert a delta in seconds into a timedelta."""
696 return datetime.timedelta(seconds=seconds)
699 MinuteOfDay = NewType("MinuteOfDay", int)
702 def minute_number(hour: int, minute: int) -> MinuteOfDay:
704 Convert hour:minute into minute number from start of day.
706 >>> minute_number(0, 0)
709 >>> minute_number(9, 15)
712 >>> minute_number(23, 59)
716 return MinuteOfDay(hour * 60 + minute)
719 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
721 Convert a datetime into a minute number (of the day). Note that
722 this ignores the date part of the datetime and only uses the time
725 >>> d = string_to_datetime(
726 ... "2021/09/10 11:24:51AM-0700",
729 >>> datetime_to_minute_number(d)
733 return minute_number(dt.hour, dt.minute)
736 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
738 Convert a datetime.time into a minute number.
740 >>> t = datetime.time(5, 15)
741 >>> time_to_minute_number(t)
745 return minute_number(t.hour, t.minute)
748 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
750 Convert minute number from start of day into hour:minute am/pm
753 >>> minute_number_to_time_string(315)
756 >>> minute_number_to_time_string(684)
760 hour = minute_num // 60
761 minute = minute_num % 60
770 return f"{hour:2}:{minute:02}{ampm}"
773 def parse_duration(duration: str) -> int:
775 Parse a duration in string form into a delta seconds.
777 >>> parse_duration('15 days, 2 hours')
780 >>> parse_duration('15d 2h')
783 >>> parse_duration('100s')
786 >>> parse_duration('3min 2sec')
790 if duration.isdigit():
793 m = re.search(r'(\d+) *d[ays]*', duration)
795 seconds += int(m.group(1)) * 60 * 60 * 24
796 m = re.search(r'(\d+) *h[ours]*', duration)
798 seconds += int(m.group(1)) * 60 * 60
799 m = re.search(r'(\d+) *m[inutes]*', duration)
801 seconds += int(m.group(1)) * 60
802 m = re.search(r'(\d+) *s[econds]*', duration)
804 seconds += int(m.group(1))
808 def describe_duration(seconds: int, *, include_seconds=False) -> str:
810 Describe a duration represented as a count of seconds nicely.
812 >>> describe_duration(182)
815 >>> describe_duration(182, include_seconds=True)
816 '3 minutes, and 2 seconds'
818 >>> describe_duration(100, include_seconds=True)
819 '1 minute, and 40 seconds'
821 describe_duration(1303200)
825 days = divmod(seconds, constants.SECONDS_PER_DAY)
826 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
827 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
831 descr = f"{int(days[0])} days, "
836 descr = descr + f"{int(hours[0])} hours, "
838 descr = descr + "1 hour, "
840 if not include_seconds and len(descr) > 0:
841 descr = descr + "and "
844 descr = descr + "1 minute"
846 descr = descr + f"{int(minutes[0])} minutes"
851 descr = descr + 'and '
854 descr = descr + '1 second'
856 descr = descr + f'{s} seconds'
860 def describe_timedelta(delta: datetime.timedelta) -> str:
862 Describe a duration represented by a timedelta object.
864 >>> d = datetime.timedelta(1, 600)
865 >>> describe_timedelta(d)
866 '1 day, and 10 minutes'
869 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
872 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
874 Describe a duration briefly.
876 >>> describe_duration_briefly(182)
879 >>> describe_duration_briefly(182, include_seconds=True)
882 >>> describe_duration_briefly(100, include_seconds=True)
885 describe_duration_briefly(1303200)
889 days = divmod(seconds, constants.SECONDS_PER_DAY)
890 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
891 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
895 descr = f'{int(days[0])}d '
897 descr = descr + f'{int(hours[0])}h '
898 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
899 descr = descr + f'{int(minutes[0])}m '
900 if minutes[1] > 0 and include_seconds:
901 descr = descr + f'{int(minutes[1])}s'
905 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
907 Describe a duration represented by a timedelta object.
909 >>> d = datetime.timedelta(1, 600)
910 >>> describe_timedelta_briefly(d)
914 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
917 if __name__ == '__main__':