3 # © Copyright 2021-2022, Scott Gasch
5 """Utilities related to dates, 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 """Returns true if the datetime argument is timezone aware or
25 See: https://docs.python.org/3/library/datetime.html
26 #determining-if-an-object-is-aware-or-naive
29 dt: The datetime object to check
31 >>> is_timezone_aware(datetime.datetime.now())
34 >>> is_timezone_aware(now_pacific())
38 return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
41 def is_timezone_naive(dt: datetime.datetime) -> bool:
42 """Inverse of is_timezone_aware -- returns true if the dt argument
45 See: https://docs.python.org/3/library/datetime.html
46 #determining-if-an-object-is-aware-or-naive
49 dt: The datetime object to check
51 >>> is_timezone_naive(datetime.datetime.now())
54 >>> is_timezone_naive(now_pacific())
58 return not is_timezone_aware(dt)
61 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
62 """Remove the timezone from a datetime.
66 This does not change the hours, minutes, seconds,
67 months, days, years, etc... Thus the instant to which this
68 timestamp refers will change. Silently ignores datetimes
69 which are already timezone naive.
71 >>> now = now_pacific()
72 >>> now.tzinfo == None
75 >>> dt = strip_timezone(now)
82 >>> dt.hour == now.hour
86 if is_timezone_naive(dt):
88 return replace_timezone(dt, None)
91 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
93 Adds a timezone to a timezone naive datetime. This does not
94 change the instant to which the timestamp refers. See also:
97 >>> now = datetime.datetime.now()
98 >>> is_timezone_aware(now)
101 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
102 >>> is_timezone_aware(now_pacific)
105 >>> now.hour == now_pacific.hour
107 >>> now.minute == now_pacific.minute
112 # This doesn't work, tz requires a timezone naive dt. Two options
114 # 1. Use strip_timezone and try again.
115 # 2. Replace the timezone on your dt object via replace_timezone.
116 # Be aware that this changes the instant to which the dt refers
117 # and, further, can introduce weirdness like UTC offsets that
118 # are weird (e.g. not an even multiple of an hour, etc...)
119 if is_timezone_aware(dt):
123 f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
124 + 'depending on the semantics you want. See the pydocs / code.'
126 return dt.replace(tzinfo=tz)
129 def replace_timezone(dt: datetime.datetime, tz: Optional[datetime.tzinfo]) -> datetime.datetime:
130 """Replaces the timezone on a timezone aware datetime object directly
131 (leaving the year, month, day, hour, minute, second, micro,
134 Works with timezone aware and timezone naive dts but for the
135 latter it is probably better to use add_timezone or just create it
136 with a tz parameter. Using this can have weird side effects like
137 UTC offsets that are not an even multiple of an hour, etc...
141 This changes the instant to which this dt refers.
143 >>> from pytz import UTC
144 >>> d = now_pacific()
145 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
148 >>> o = replace_timezone(d, UTC)
149 >>> o.tzinfo.tzname(o)
155 if is_timezone_aware(dt):
157 '%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.',
160 return datetime.datetime(
172 return add_timezone(dt, tz)
177 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
178 """Replaces the timezone on a datetime.time directly without performing
183 Note that, as above, this will change the instant to
184 which the time refers.
186 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
190 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
194 return t.replace(tzinfo=tz)
197 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
199 Translates dt into a different timezone by adjusting the year, month,
200 day, hour, minute, second, micro, etc... appropriately. The returned
201 dt is the same instant in another timezone.
203 >>> from pytz import UTC
204 >>> d = now_pacific()
205 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
208 >>> o = translate_timezone(d, UTC)
209 >>> o.tzinfo.tzname(o)
215 return dt.replace(tzinfo=None).astimezone(tz=tz)
218 def now() -> datetime.datetime:
220 What time is it? Result is a timezone naive datetime.
222 return datetime.datetime.now()
225 def now_pacific() -> datetime.datetime:
227 What time is it? Result in US/Pacific time (PST/PDT)
229 return datetime.datetime.now(pytz.timezone("US/Pacific"))
232 def date_to_datetime(date: datetime.date) -> datetime.datetime:
234 Given a date, return a datetime with hour/min/sec zero (midnight)
237 >>> date_to_datetime(datetime.date(2021, 12, 25))
238 datetime.datetime(2021, 12, 25, 0, 0)
241 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
244 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
246 Given a time, returns that time as a datetime with a date component
247 set based on the current date. If the time passed is timezone aware,
248 the resulting datetime will also be (and will use the same tzinfo).
249 If the time is timezone naive, the datetime returned will be too.
251 >>> t = datetime.time(13, 14, 0)
252 >>> d = now_pacific().date()
253 >>> dt = time_to_datetime_today(t)
260 >>> dt.tzinfo == t.tzinfo
263 >>> dt.tzinfo == None
266 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
270 >>> dt = time_to_datetime_today(t)
271 >>> dt.tzinfo == None
276 return datetime.datetime.combine(now_pacific(), time, tz)
279 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
281 Given a date and time, merge them and return a datetime.
284 >>> d = datetime.date(2021, 12, 25)
285 >>> t = datetime.time(12, 30, 0, 0)
286 >>> date_and_time_to_datetime(d, t)
287 datetime.datetime(2021, 12, 25, 12, 30)
290 return datetime.datetime(
301 def datetime_to_date_and_time(
302 dt: datetime.datetime,
303 ) -> Tuple[datetime.date, datetime.time]:
304 """Return the component date and time objects of a datetime in a
305 Tuple given a datetime.
308 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
309 >>> (d, t) = datetime_to_date_and_time(dt)
311 datetime.date(2021, 12, 25)
313 datetime.time(12, 30)
316 return (dt.date(), dt.timetz())
319 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
320 """Return just the date part of a datetime.
323 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
324 >>> datetime_to_date(dt)
325 datetime.date(2021, 12, 25)
328 return datetime_to_date_and_time(dt)[0]
331 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
332 """Return just the time part of a datetime.
335 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
336 >>> datetime_to_time(dt)
337 datetime.time(12, 30)
340 return datetime_to_date_and_time(dt)[1]
343 class TimeUnit(enum.IntEnum):
344 """An enum to represent units with which we can compute deltas."""
363 def is_valid(cls, value: Any):
364 if isinstance(value, int):
365 return cls(value) is not None
366 elif isinstance(value, TimeUnit):
367 return cls(value.value) is not None
368 elif isinstance(value, str):
369 return cls.__members__[value] is not None
375 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
376 """Return a datetime that is N units before/after a base datetime.
377 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
378 years before base datetime, 13 minutes after base datetime, etc...
379 Note: to indicate before/after the base date, use a positive or
382 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
384 The next (1) Monday from the base datetime:
385 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
386 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
388 Ten (10) years after the base datetime:
389 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
390 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
392 Fifty (50) working days (M..F, not counting holidays) after base datetime:
393 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
394 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
396 Fifty (50) days (including weekends and holidays) after base datetime:
397 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
398 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
400 Fifty (50) months before (note negative count) base datetime:
401 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
402 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
404 Fifty (50) hours after base datetime:
405 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
406 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
408 Fifty (50) minutes before base datetime:
409 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
410 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
412 Fifty (50) seconds from base datetime:
413 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
414 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
416 Next month corner case -- it will try to make Feb 31, 2022 then count
418 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
419 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
420 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
422 Last month with the same corner case
423 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
424 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
425 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
428 assert TimeUnit.is_valid(unit)
433 if unit == TimeUnit.DAYS:
434 timedelta = datetime.timedelta(days=count)
435 return base + timedelta
438 elif unit == TimeUnit.HOURS:
439 timedelta = datetime.timedelta(hours=count)
440 return base + timedelta
442 # N minutes from base
443 elif unit == TimeUnit.MINUTES:
444 timedelta = datetime.timedelta(minutes=count)
445 return base + timedelta
447 # N seconds from base
448 elif unit == TimeUnit.SECONDS:
449 timedelta = datetime.timedelta(seconds=count)
450 return base + timedelta
452 # N workdays from base
453 elif unit == TimeUnit.WORKDAYS:
456 timedelta = datetime.timedelta(days=-1)
458 timedelta = datetime.timedelta(days=1)
459 skips = holidays.US(years=base.year).keys()
463 if base.year != old_year:
464 skips = holidays.US(years=base.year).keys()
465 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
470 elif unit == TimeUnit.WEEKS:
471 timedelta = datetime.timedelta(weeks=count)
472 base = base + timedelta
476 elif unit == TimeUnit.MONTHS:
477 month_term = count % 12
478 year_term = count // 12
479 new_month = base.month + month_term
483 new_year = base.year + year_term
487 ret = datetime.datetime(
503 elif unit == TimeUnit.YEARS:
504 new_year = base.year + count
505 return datetime.datetime(
527 raise ValueError(unit)
529 # N weekdays from base (e.g. 4 wednesdays from today)
530 direction = 1 if count > 0 else -1
532 timedelta = datetime.timedelta(days=direction)
536 if dow == unit.value and start != base:
540 base = base + timedelta
543 def get_format_string(
545 date_time_separator=" ",
546 include_timezone=True,
547 include_dayname=False,
548 use_month_abbrevs=False,
549 include_seconds=True,
550 include_fractional=False,
554 Helper to return a format string without looking up the documentation
557 >>> get_format_string()
558 '%Y/%m/%d %I:%M:%S%p%z'
560 >>> get_format_string(date_time_separator='@')
561 '%Y/%m/%d@%I:%M:%S%p%z'
563 >>> get_format_string(include_dayname=True)
564 '%a/%Y/%m/%d %I:%M:%S%p%z'
566 >>> get_format_string(include_dayname=True, twelve_hour=False)
567 '%a/%Y/%m/%d %H:%M:%S%z'
574 if use_month_abbrevs:
575 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
577 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
587 if include_fractional:
594 def datetime_to_string(
595 dt: datetime.datetime,
597 date_time_separator=" ",
598 include_timezone=True,
599 include_dayname=False,
600 use_month_abbrevs=False,
601 include_seconds=True,
602 include_fractional=False,
606 A nice way to convert a datetime into a string; arguably better than
607 just printing it and relying on it __repr__().
609 >>> d = string_to_datetime(
610 ... "2021/09/10 11:24:51AM-0700",
613 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
614 >>> datetime_to_string(d)
615 '2021/09/10 11:24:51AM-0700'
616 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
617 'Fri/2021/09/10 11:24AM-0700'
620 fstring = get_format_string(
621 date_time_separator=date_time_separator,
622 include_timezone=include_timezone,
623 include_dayname=include_dayname,
624 use_month_abbrevs=use_month_abbrevs,
625 include_seconds=include_seconds,
626 include_fractional=include_fractional,
627 twelve_hour=twelve_hour,
629 return dt.strftime(fstring).strip()
632 def string_to_datetime(
635 date_time_separator=" ",
636 include_timezone=True,
637 include_dayname=False,
638 use_month_abbrevs=False,
639 include_seconds=True,
640 include_fractional=False,
642 ) -> Tuple[datetime.datetime, str]:
643 """A nice way to convert a string into a datetime. Returns both the
644 datetime and the format string used to parse it. Also consider
645 dateparse.dateparse_utils for a full parser alternative.
647 >>> d = string_to_datetime(
648 ... "2021/09/10 11:24:51AM-0700",
651 (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')
654 fstring = get_format_string(
655 date_time_separator=date_time_separator,
656 include_timezone=include_timezone,
657 include_dayname=include_dayname,
658 use_month_abbrevs=use_month_abbrevs,
659 include_seconds=include_seconds,
660 include_fractional=include_fractional,
661 twelve_hour=twelve_hour,
663 return (datetime.datetime.strptime(txt, fstring), fstring)
666 def timestamp() -> str:
667 """Return a timestamp for right now in Pacific timezone."""
668 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
669 return datetime_to_string(ts, include_timezone=True)
673 dt: datetime.datetime,
675 include_seconds=True,
676 include_fractional=False,
677 include_timezone=False,
680 """A nice way to convert a datetime into a time (only) string.
681 This ignores the date part of the datetime.
683 >>> d = string_to_datetime(
684 ... "2021/09/10 11:24:51AM-0700",
687 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
689 >>> time_to_string(d)
692 >>> time_to_string(d, include_seconds=False)
695 >>> time_to_string(d, include_seconds=False, include_timezone=True)
709 if include_fractional:
713 return dt.strftime(fstring).strip()
716 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
717 """Convert a delta in seconds into a timedelta."""
718 return datetime.timedelta(seconds=seconds)
721 MinuteOfDay = NewType("MinuteOfDay", int)
724 def minute_number(hour: int, minute: int) -> MinuteOfDay:
726 Convert hour:minute into minute number from start of day.
728 >>> minute_number(0, 0)
731 >>> minute_number(9, 15)
734 >>> minute_number(23, 59)
738 return MinuteOfDay(hour * 60 + minute)
741 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
743 Convert a datetime into a minute number (of the day). Note that
744 this ignores the date part of the datetime and only uses the time
747 >>> d = string_to_datetime(
748 ... "2021/09/10 11:24:51AM-0700",
751 >>> datetime_to_minute_number(d)
755 return minute_number(dt.hour, dt.minute)
758 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
760 Convert a datetime.time into a minute number.
762 >>> t = datetime.time(5, 15)
763 >>> time_to_minute_number(t)
767 return minute_number(t.hour, t.minute)
770 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
772 Convert minute number from start of day into hour:minute am/pm
775 >>> minute_number_to_time_string(315)
778 >>> minute_number_to_time_string(684)
782 hour = minute_num // 60
783 minute = minute_num % 60
792 return f"{hour:2}:{minute:02}{ampm}"
795 def parse_duration(duration: str) -> int:
797 Parse a duration in string form into a delta seconds.
799 >>> parse_duration('15 days, 2 hours')
802 >>> parse_duration('15d 2h')
805 >>> parse_duration('100s')
808 >>> parse_duration('3min 2sec')
812 if duration.isdigit():
815 m = re.search(r'(\d+) *d[ays]*', duration)
817 seconds += int(m.group(1)) * 60 * 60 * 24
818 m = re.search(r'(\d+) *h[ours]*', duration)
820 seconds += int(m.group(1)) * 60 * 60
821 m = re.search(r'(\d+) *m[inutes]*', duration)
823 seconds += int(m.group(1)) * 60
824 m = re.search(r'(\d+) *s[econds]*', duration)
826 seconds += int(m.group(1))
830 def describe_duration(seconds: int, *, include_seconds=False) -> str:
832 Describe a duration represented as a count of seconds nicely.
834 >>> describe_duration(182)
837 >>> describe_duration(182, include_seconds=True)
838 '3 minutes, and 2 seconds'
840 >>> describe_duration(100, include_seconds=True)
841 '1 minute, and 40 seconds'
843 describe_duration(1303200)
847 days = divmod(seconds, constants.SECONDS_PER_DAY)
848 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
849 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
853 descr = f"{int(days[0])} days, "
858 descr = descr + f"{int(hours[0])} hours, "
860 descr = descr + "1 hour, "
862 if not include_seconds and len(descr) > 0:
863 descr = descr + "and "
866 descr = descr + "1 minute"
868 descr = descr + f"{int(minutes[0])} minutes"
873 descr = descr + 'and '
876 descr = descr + '1 second'
878 descr = descr + f'{s} seconds'
882 def describe_timedelta(delta: datetime.timedelta) -> str:
884 Describe a duration represented by a timedelta object.
886 >>> d = datetime.timedelta(1, 600)
887 >>> describe_timedelta(d)
888 '1 day, and 10 minutes'
891 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
894 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
896 Describe a duration briefly.
898 >>> describe_duration_briefly(182)
901 >>> describe_duration_briefly(182, include_seconds=True)
904 >>> describe_duration_briefly(100, include_seconds=True)
907 describe_duration_briefly(1303200)
911 days = divmod(seconds, constants.SECONDS_PER_DAY)
912 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
913 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
917 descr = f'{int(days[0])}d '
919 descr = descr + f'{int(hours[0])}h '
920 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
921 descr = descr + f'{int(minutes[0])}m '
922 if minutes[1] > 0 and include_seconds:
923 descr = descr + f'{int(minutes[1])}s'
927 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
929 Describe a duration represented by a timedelta object.
931 >>> d = datetime.timedelta(1, 600)
932 >>> describe_timedelta_briefly(d)
936 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
939 if __name__ == '__main__':