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.
204 >>> d = now_pacific()
205 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
208 >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
209 >>> o.tzinfo.tzname(o)[0] # Again, could be EST or EDT
213 >>> expected = h + 3 # Three hours later in E?T than P?T
214 >>> expected = expected % 24 # Handle edge case
215 >>> expected == o.hour
218 return dt.replace().astimezone(tz=tz)
221 def now() -> datetime.datetime:
223 What time is it? Result is a timezone naive datetime.
225 return datetime.datetime.now()
228 def now_pacific() -> datetime.datetime:
230 What time is it? Result in US/Pacific time (PST/PDT)
232 return datetime.datetime.now(pytz.timezone("US/Pacific"))
235 def date_to_datetime(date: datetime.date) -> datetime.datetime:
237 Given a date, return a datetime with hour/min/sec zero (midnight)
240 >>> date_to_datetime(datetime.date(2021, 12, 25))
241 datetime.datetime(2021, 12, 25, 0, 0)
244 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
247 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
249 Given a time, returns that time as a datetime with a date component
250 set based on the current date. If the time passed is timezone aware,
251 the resulting datetime will also be (and will use the same tzinfo).
252 If the time is timezone naive, the datetime returned will be too.
254 >>> t = datetime.time(13, 14, 0)
255 >>> d = now_pacific().date()
256 >>> dt = time_to_datetime_today(t)
263 >>> dt.tzinfo == t.tzinfo
266 >>> dt.tzinfo == None
269 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
273 >>> dt = time_to_datetime_today(t)
274 >>> dt.tzinfo == None
279 return datetime.datetime.combine(now_pacific(), time, tz)
282 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
284 Given a date and time, merge them and return a datetime.
287 >>> d = datetime.date(2021, 12, 25)
288 >>> t = datetime.time(12, 30, 0, 0)
289 >>> date_and_time_to_datetime(d, t)
290 datetime.datetime(2021, 12, 25, 12, 30)
293 return datetime.datetime(
304 def datetime_to_date_and_time(
305 dt: datetime.datetime,
306 ) -> Tuple[datetime.date, datetime.time]:
307 """Return the component date and time objects of a datetime in a
308 Tuple given a datetime.
311 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
312 >>> (d, t) = datetime_to_date_and_time(dt)
314 datetime.date(2021, 12, 25)
316 datetime.time(12, 30)
319 return (dt.date(), dt.timetz())
322 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
323 """Return just the date part of a datetime.
326 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
327 >>> datetime_to_date(dt)
328 datetime.date(2021, 12, 25)
331 return datetime_to_date_and_time(dt)[0]
334 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
335 """Return just the time part of a datetime.
338 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
339 >>> datetime_to_time(dt)
340 datetime.time(12, 30)
343 return datetime_to_date_and_time(dt)[1]
346 class TimeUnit(enum.IntEnum):
347 """An enum to represent units with which we can compute deltas."""
366 def is_valid(cls, value: Any):
367 if isinstance(value, int):
368 return cls(value) is not None
369 elif isinstance(value, TimeUnit):
370 return cls(value.value) is not None
371 elif isinstance(value, str):
372 return cls.__members__[value] is not None
378 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
379 """Return a datetime that is N units before/after a base datetime.
380 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
381 years before base datetime, 13 minutes after base datetime, etc...
382 Note: to indicate before/after the base date, use a positive or
385 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
387 The next (1) Monday from the base datetime:
388 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
389 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
391 Ten (10) years after the base datetime:
392 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
393 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
395 Fifty (50) working days (M..F, not counting holidays) after base datetime:
396 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
397 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
399 Fifty (50) days (including weekends and holidays) after base datetime:
400 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
401 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
403 Fifty (50) months before (note negative count) base datetime:
404 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
405 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
407 Fifty (50) hours after base datetime:
408 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
409 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
411 Fifty (50) minutes before base datetime:
412 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
413 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
415 Fifty (50) seconds from base datetime:
416 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
417 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
419 Next month corner case -- it will try to make Feb 31, 2022 then count
421 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
422 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
423 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
425 Last month with the same corner case
426 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
427 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
428 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
431 assert TimeUnit.is_valid(unit)
436 if unit == TimeUnit.DAYS:
437 timedelta = datetime.timedelta(days=count)
438 return base + timedelta
441 elif unit == TimeUnit.HOURS:
442 timedelta = datetime.timedelta(hours=count)
443 return base + timedelta
445 # N minutes from base
446 elif unit == TimeUnit.MINUTES:
447 timedelta = datetime.timedelta(minutes=count)
448 return base + timedelta
450 # N seconds from base
451 elif unit == TimeUnit.SECONDS:
452 timedelta = datetime.timedelta(seconds=count)
453 return base + timedelta
455 # N workdays from base
456 elif unit == TimeUnit.WORKDAYS:
459 timedelta = datetime.timedelta(days=-1)
461 timedelta = datetime.timedelta(days=1)
462 skips = holidays.US(years=base.year).keys()
466 if base.year != old_year:
467 skips = holidays.US(years=base.year).keys()
468 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
473 elif unit == TimeUnit.WEEKS:
474 timedelta = datetime.timedelta(weeks=count)
475 base = base + timedelta
479 elif unit == TimeUnit.MONTHS:
480 month_term = count % 12
481 year_term = count // 12
482 new_month = base.month + month_term
486 new_year = base.year + year_term
490 ret = datetime.datetime(
506 elif unit == TimeUnit.YEARS:
507 new_year = base.year + count
508 return datetime.datetime(
530 raise ValueError(unit)
532 # N weekdays from base (e.g. 4 wednesdays from today)
533 direction = 1 if count > 0 else -1
535 timedelta = datetime.timedelta(days=direction)
539 if dow == unit.value and start != base:
543 base = base + timedelta
546 def get_format_string(
548 date_time_separator=" ",
549 include_timezone=True,
550 include_dayname=False,
551 use_month_abbrevs=False,
552 include_seconds=True,
553 include_fractional=False,
557 Helper to return a format string without looking up the documentation
560 >>> get_format_string()
561 '%Y/%m/%d %I:%M:%S%p%z'
563 >>> get_format_string(date_time_separator='@')
564 '%Y/%m/%d@%I:%M:%S%p%z'
566 >>> get_format_string(include_dayname=True)
567 '%a/%Y/%m/%d %I:%M:%S%p%z'
569 >>> get_format_string(include_dayname=True, twelve_hour=False)
570 '%a/%Y/%m/%d %H:%M:%S%z'
577 if use_month_abbrevs:
578 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
580 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
590 if include_fractional:
597 def datetime_to_string(
598 dt: datetime.datetime,
600 date_time_separator=" ",
601 include_timezone=True,
602 include_dayname=False,
603 use_month_abbrevs=False,
604 include_seconds=True,
605 include_fractional=False,
609 A nice way to convert a datetime into a string; arguably better than
610 just printing it and relying on it __repr__().
612 >>> d = string_to_datetime(
613 ... "2021/09/10 11:24:51AM-0700",
616 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
617 >>> datetime_to_string(d)
618 '2021/09/10 11:24:51AM-0700'
619 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
620 'Fri/2021/09/10 11:24AM-0700'
623 fstring = get_format_string(
624 date_time_separator=date_time_separator,
625 include_timezone=include_timezone,
626 include_dayname=include_dayname,
627 use_month_abbrevs=use_month_abbrevs,
628 include_seconds=include_seconds,
629 include_fractional=include_fractional,
630 twelve_hour=twelve_hour,
632 return dt.strftime(fstring).strip()
635 def string_to_datetime(
638 date_time_separator=" ",
639 include_timezone=True,
640 include_dayname=False,
641 use_month_abbrevs=False,
642 include_seconds=True,
643 include_fractional=False,
645 ) -> Tuple[datetime.datetime, str]:
646 """A nice way to convert a string into a datetime. Returns both the
647 datetime and the format string used to parse it. Also consider
648 dateparse.dateparse_utils for a full parser alternative.
650 >>> d = string_to_datetime(
651 ... "2021/09/10 11:24:51AM-0700",
654 (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')
657 fstring = get_format_string(
658 date_time_separator=date_time_separator,
659 include_timezone=include_timezone,
660 include_dayname=include_dayname,
661 use_month_abbrevs=use_month_abbrevs,
662 include_seconds=include_seconds,
663 include_fractional=include_fractional,
664 twelve_hour=twelve_hour,
666 return (datetime.datetime.strptime(txt, fstring), fstring)
669 def timestamp() -> str:
670 """Return a timestamp for right now in Pacific timezone."""
671 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
672 return datetime_to_string(ts, include_timezone=True)
676 dt: datetime.datetime,
678 include_seconds=True,
679 include_fractional=False,
680 include_timezone=False,
683 """A nice way to convert a datetime into a time (only) string.
684 This ignores the date part of the datetime.
686 >>> d = string_to_datetime(
687 ... "2021/09/10 11:24:51AM-0700",
690 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
692 >>> time_to_string(d)
695 >>> time_to_string(d, include_seconds=False)
698 >>> time_to_string(d, include_seconds=False, include_timezone=True)
712 if include_fractional:
716 return dt.strftime(fstring).strip()
719 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
720 """Convert a delta in seconds into a timedelta."""
721 return datetime.timedelta(seconds=seconds)
724 MinuteOfDay = NewType("MinuteOfDay", int)
727 def minute_number(hour: int, minute: int) -> MinuteOfDay:
729 Convert hour:minute into minute number from start of day.
731 >>> minute_number(0, 0)
734 >>> minute_number(9, 15)
737 >>> minute_number(23, 59)
741 return MinuteOfDay(hour * 60 + minute)
744 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
746 Convert a datetime into a minute number (of the day). Note that
747 this ignores the date part of the datetime and only uses the time
750 >>> d = string_to_datetime(
751 ... "2021/09/10 11:24:51AM-0700",
754 >>> datetime_to_minute_number(d)
758 return minute_number(dt.hour, dt.minute)
761 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
763 Convert a datetime.time into a minute number.
765 >>> t = datetime.time(5, 15)
766 >>> time_to_minute_number(t)
770 return minute_number(t.hour, t.minute)
773 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
775 Convert minute number from start of day into hour:minute am/pm
778 >>> minute_number_to_time_string(315)
781 >>> minute_number_to_time_string(684)
785 hour = minute_num // 60
786 minute = minute_num % 60
795 return f"{hour:2}:{minute:02}{ampm}"
798 def parse_duration(duration: str) -> int:
800 Parse a duration in string form into a delta seconds.
802 >>> parse_duration('15 days, 2 hours')
805 >>> parse_duration('15d 2h')
808 >>> parse_duration('100s')
811 >>> parse_duration('3min 2sec')
815 if duration.isdigit():
818 m = re.search(r'(\d+) *d[ays]*', duration)
820 seconds += int(m.group(1)) * 60 * 60 * 24
821 m = re.search(r'(\d+) *h[ours]*', duration)
823 seconds += int(m.group(1)) * 60 * 60
824 m = re.search(r'(\d+) *m[inutes]*', duration)
826 seconds += int(m.group(1)) * 60
827 m = re.search(r'(\d+) *s[econds]*', duration)
829 seconds += int(m.group(1))
833 def describe_duration(seconds: int, *, include_seconds=False) -> str:
835 Describe a duration represented as a count of seconds nicely.
837 >>> describe_duration(182)
840 >>> describe_duration(182, include_seconds=True)
841 '3 minutes, and 2 seconds'
843 >>> describe_duration(100, include_seconds=True)
844 '1 minute, and 40 seconds'
846 describe_duration(1303200)
850 days = divmod(seconds, constants.SECONDS_PER_DAY)
851 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
852 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
856 descr = f"{int(days[0])} days, "
861 descr = descr + f"{int(hours[0])} hours, "
863 descr = descr + "1 hour, "
865 if not include_seconds and len(descr) > 0:
866 descr = descr + "and "
869 descr = descr + "1 minute"
871 descr = descr + f"{int(minutes[0])} minutes"
876 descr = descr + 'and '
879 descr = descr + '1 second'
881 descr = descr + f'{s} seconds'
885 def describe_timedelta(delta: datetime.timedelta) -> str:
887 Describe a duration represented by a timedelta object.
889 >>> d = datetime.timedelta(1, 600)
890 >>> describe_timedelta(d)
891 '1 day, and 10 minutes'
894 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
897 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
899 Describe a duration briefly.
901 >>> describe_duration_briefly(182)
904 >>> describe_duration_briefly(182, include_seconds=True)
907 >>> describe_duration_briefly(100, include_seconds=True)
910 describe_duration_briefly(1303200)
914 days = divmod(seconds, constants.SECONDS_PER_DAY)
915 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
916 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
920 descr = f'{int(days[0])}d '
922 descr = descr + f'{int(hours[0])}h '
923 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
924 descr = descr + f'{int(minutes[0])}m '
925 if minutes[1] > 0 and include_seconds:
926 descr = descr + f'{int(minutes[1])}s'
930 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
932 Describe a duration represented by a timedelta object.
934 >>> d = datetime.timedelta(1, 600)
935 >>> describe_timedelta_briefly(d)
939 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
942 if __name__ == '__main__':