3 """Utilities related to dates and times and datetimes."""
9 from typing import Any, NewType, Optional, Tuple
11 import holidays # type: ignore
16 logger = logging.getLogger(__name__)
19 def is_timezone_aware(dt: datetime.datetime) -> bool:
20 """See: https://docs.python.org/3/library/datetime.html
21 #determining-if-an-object-is-aware-or-naive
23 >>> is_timezone_aware(datetime.datetime.now())
26 >>> is_timezone_aware(now_pacific())
30 return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
33 def is_timezone_naive(dt: datetime.datetime) -> bool:
34 """Inverse of is_timezone_aware.
36 >>> is_timezone_naive(datetime.datetime.now())
39 >>> is_timezone_naive(now_pacific())
43 return not is_timezone_aware(dt)
46 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
47 """Remove the timezone from a datetime. Does not change the
48 hours, minutes, seconds, months, days, years, etc... Thus the
49 instant to which this timestamp refers will change. Silently
50 ignores datetimes which are already timezone naive.
52 >>> now = now_pacific()
53 >>> now.tzinfo == None
56 >>> dt = strip_timezone(now)
63 >>> dt.hour == now.hour
67 if is_timezone_naive(dt):
69 return replace_timezone(dt, None)
72 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
74 Adds a timezone to a timezone naive datetime. This does not
75 change the instant to which the timestamp refers. See also:
78 >>> now = datetime.datetime.now()
79 >>> is_timezone_aware(now)
82 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
83 >>> is_timezone_aware(now_pacific)
86 >>> now.hour == now_pacific.hour
88 >>> now.minute == now_pacific.minute
93 # This doesn't work, tz requires a timezone naive dt. Two options
95 # 1. Use strip_timezone and try again.
96 # 2. Replace the timezone on your dt object via replace_timezone.
97 # Be aware that this changes the instant to which the dt refers
98 # and, further, can introduce weirdness like UTC offsets that
99 # are weird (e.g. not an even multiple of an hour, etc...)
100 if is_timezone_aware(dt):
104 f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
105 + 'depending on the semantics you want.'
107 return dt.replace(tzinfo=tz)
110 def replace_timezone(dt: datetime.datetime, tz: Optional[datetime.tzinfo]) -> datetime.datetime:
111 """Replaces the timezone on a timezone aware datetime object directly
112 (leaving the year, month, day, hour, minute, second, micro,
115 Works with timezone aware and timezone naive dts but for the
116 latter it is probably better to use add_timezone or just create it
117 with a tz parameter. Using this can have weird side effects like
118 UTC offsets that are not an even multiple of an hour, etc...
120 Note: this changes the instant to which this dt refers.
122 >>> from pytz import UTC
123 >>> d = now_pacific()
124 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
127 >>> o = replace_timezone(d, UTC)
128 >>> o.tzinfo.tzname(o)
134 if is_timezone_aware(dt):
136 '%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.',
139 return datetime.datetime(
151 return add_timezone(dt, tz)
156 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
158 Replaces the timezone on a datetime.time directly without performing
159 any translation. Note that, as above, this will change the instant
160 to which the time refers.
162 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
166 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
171 return t.replace(tzinfo=tz)
174 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
176 Translates dt into a different timezone by adjusting the year, month,
177 day, hour, minute, second, micro, etc... appropriately. The returned
178 dt is the same instant in another timezone.
180 >>> from pytz import UTC
181 >>> d = now_pacific()
182 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
185 >>> o = translate_timezone(d, UTC)
186 >>> o.tzinfo.tzname(o)
192 return dt.replace(tzinfo=None).astimezone(tz=tz)
195 def now() -> datetime.datetime:
197 What time is it? Returned as a timezone naive datetime.
199 return datetime.datetime.now()
202 def now_pacific() -> datetime.datetime:
204 What time is it? Result in US/Pacific time (PST/PDT)
206 return datetime.datetime.now(pytz.timezone("US/Pacific"))
209 def date_to_datetime(date: datetime.date) -> datetime.datetime:
211 Given a date, return a datetime with hour/min/sec zero (midnight)
214 >>> date_to_datetime(datetime.date(2021, 12, 25))
215 datetime.datetime(2021, 12, 25, 0, 0)
218 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
221 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
223 Given a time, returns that time as a datetime with a date component
224 set based on the current date. If the time passed is timezone aware,
225 the resulting datetime will also be (and will use the same tzinfo).
226 If the time is timezone naive, the datetime returned will be too.
228 >>> t = datetime.time(13, 14, 0)
229 >>> d = now_pacific().date()
230 >>> dt = time_to_datetime_today(t)
237 >>> dt.tzinfo == t.tzinfo
240 >>> dt.tzinfo == None
243 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
247 >>> dt = time_to_datetime_today(t)
248 >>> dt.tzinfo == None
253 return datetime.datetime.combine(now_pacific(), time, tz)
256 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
258 Given a date and time, merge them and return a datetime.
261 >>> d = datetime.date(2021, 12, 25)
262 >>> t = datetime.time(12, 30, 0, 0)
263 >>> date_and_time_to_datetime(d, t)
264 datetime.datetime(2021, 12, 25, 12, 30)
267 return datetime.datetime(
278 def datetime_to_date_and_time(
279 dt: datetime.datetime,
280 ) -> Tuple[datetime.date, datetime.time]:
281 """Return the component date and time objects of a datetime.
284 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
285 >>> (d, t) = datetime_to_date_and_time(dt)
287 datetime.date(2021, 12, 25)
289 datetime.time(12, 30)
292 return (dt.date(), dt.timetz())
295 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
296 """Return the date part of a datetime.
299 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
300 >>> datetime_to_date(dt)
301 datetime.date(2021, 12, 25)
304 return datetime_to_date_and_time(dt)[0]
307 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
308 """Return the time part of a datetime.
311 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
312 >>> datetime_to_time(dt)
313 datetime.time(12, 30)
316 return datetime_to_date_and_time(dt)[1]
319 class TimeUnit(enum.IntEnum):
320 """An enum to represent units with which we can compute deltas."""
339 def is_valid(cls, value: Any):
340 if isinstance(value, int):
341 return cls(value) is not None
342 elif isinstance(value, TimeUnit):
343 return cls(value.value) is not None
344 elif isinstance(value, str):
345 return cls.__members__[value] is not None
351 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
352 """Return a datetime that is N units before/after a base datetime.
353 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
354 years before base datetime, 13 minutes after base datetime, etc...
355 Note: to indicate before/after the base date, use a positive or
358 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
360 The next (1) Monday from the base datetime:
361 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
362 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
364 Ten (10) years after the base datetime:
365 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
366 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
368 Fifty (50) working days (M..F, not counting holidays) after base datetime:
369 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
370 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
372 Fifty (50) days (including weekends and holidays) after base datetime:
373 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
374 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
376 Fifty (50) months before (note negative count) base datetime:
377 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
378 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
380 Fifty (50) hours after base datetime:
381 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
382 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
384 Fifty (50) minutes before base datetime:
385 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
386 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
388 Fifty (50) seconds from base datetime:
389 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
390 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
392 Next month corner case -- it will try to make Feb 31, 2022 then count
394 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
395 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
396 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
398 Last month with the same corner case
399 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
400 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
401 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
404 assert TimeUnit.is_valid(unit)
409 if unit == TimeUnit.DAYS:
410 timedelta = datetime.timedelta(days=count)
411 return base + timedelta
414 elif unit == TimeUnit.HOURS:
415 timedelta = datetime.timedelta(hours=count)
416 return base + timedelta
418 # N minutes from base
419 elif unit == TimeUnit.MINUTES:
420 timedelta = datetime.timedelta(minutes=count)
421 return base + timedelta
423 # N seconds from base
424 elif unit == TimeUnit.SECONDS:
425 timedelta = datetime.timedelta(seconds=count)
426 return base + timedelta
428 # N workdays from base
429 elif unit == TimeUnit.WORKDAYS:
432 timedelta = datetime.timedelta(days=-1)
434 timedelta = datetime.timedelta(days=1)
435 skips = holidays.US(years=base.year).keys()
439 if base.year != old_year:
440 skips = holidays.US(years=base.year).keys()
441 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
446 elif unit == TimeUnit.WEEKS:
447 timedelta = datetime.timedelta(weeks=count)
448 base = base + timedelta
452 elif unit == TimeUnit.MONTHS:
453 month_term = count % 12
454 year_term = count // 12
455 new_month = base.month + month_term
459 new_year = base.year + year_term
463 ret = datetime.datetime(
479 elif unit == TimeUnit.YEARS:
480 new_year = base.year + count
481 return datetime.datetime(
503 raise ValueError(unit)
505 # N weekdays from base (e.g. 4 wednesdays from today)
506 direction = 1 if count > 0 else -1
508 timedelta = datetime.timedelta(days=direction)
512 if dow == unit.value and start != base:
516 base = base + timedelta
519 def get_format_string(
521 date_time_separator=" ",
522 include_timezone=True,
523 include_dayname=False,
524 use_month_abbrevs=False,
525 include_seconds=True,
526 include_fractional=False,
530 Helper to return a format string without looking up the documentation
533 >>> get_format_string()
534 '%Y/%m/%d %I:%M:%S%p%z'
536 >>> get_format_string(date_time_separator='@')
537 '%Y/%m/%d@%I:%M:%S%p%z'
539 >>> get_format_string(include_dayname=True)
540 '%a/%Y/%m/%d %I:%M:%S%p%z'
542 >>> get_format_string(include_dayname=True, twelve_hour=False)
543 '%a/%Y/%m/%d %H:%M:%S%z'
550 if use_month_abbrevs:
551 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
553 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
563 if include_fractional:
570 def datetime_to_string(
571 dt: datetime.datetime,
573 date_time_separator=" ",
574 include_timezone=True,
575 include_dayname=False,
576 use_month_abbrevs=False,
577 include_seconds=True,
578 include_fractional=False,
582 A nice way to convert a datetime into a string; arguably better than
583 just printing it and relying on it __repr__().
585 >>> d = string_to_datetime(
586 ... "2021/09/10 11:24:51AM-0700",
589 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
590 >>> datetime_to_string(d)
591 '2021/09/10 11:24:51AM-0700'
592 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
593 'Fri/2021/09/10 11:24AM-0700'
596 fstring = get_format_string(
597 date_time_separator=date_time_separator,
598 include_timezone=include_timezone,
599 include_dayname=include_dayname,
600 use_month_abbrevs=use_month_abbrevs,
601 include_seconds=include_seconds,
602 include_fractional=include_fractional,
603 twelve_hour=twelve_hour,
605 return dt.strftime(fstring).strip()
608 def string_to_datetime(
611 date_time_separator=" ",
612 include_timezone=True,
613 include_dayname=False,
614 use_month_abbrevs=False,
615 include_seconds=True,
616 include_fractional=False,
618 ) -> Tuple[datetime.datetime, str]:
619 """A nice way to convert a string into a datetime. Returns both the
620 datetime and the format string used to parse it. Also consider
621 dateparse.dateparse_utils for a full parser alternative.
623 >>> d = string_to_datetime(
624 ... "2021/09/10 11:24:51AM-0700",
627 (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')
630 fstring = get_format_string(
631 date_time_separator=date_time_separator,
632 include_timezone=include_timezone,
633 include_dayname=include_dayname,
634 use_month_abbrevs=use_month_abbrevs,
635 include_seconds=include_seconds,
636 include_fractional=include_fractional,
637 twelve_hour=twelve_hour,
639 return (datetime.datetime.strptime(txt, fstring), fstring)
642 def timestamp() -> str:
643 """Return a timestamp for right now in Pacific timezone."""
644 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
645 return datetime_to_string(ts, include_timezone=True)
649 dt: datetime.datetime,
651 include_seconds=True,
652 include_fractional=False,
653 include_timezone=False,
656 """A nice way to convert a datetime into a time (only) string.
657 This ignores the date part of the datetime.
659 >>> d = string_to_datetime(
660 ... "2021/09/10 11:24:51AM-0700",
663 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
665 >>> time_to_string(d)
668 >>> time_to_string(d, include_seconds=False)
671 >>> time_to_string(d, include_seconds=False, include_timezone=True)
685 if include_fractional:
689 return dt.strftime(fstring).strip()
692 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
693 """Convert a delta in seconds into a timedelta."""
694 return datetime.timedelta(seconds=seconds)
697 MinuteOfDay = NewType("MinuteOfDay", int)
700 def minute_number(hour: int, minute: int) -> MinuteOfDay:
702 Convert hour:minute into minute number from start of day.
704 >>> minute_number(0, 0)
707 >>> minute_number(9, 15)
710 >>> minute_number(23, 59)
714 return MinuteOfDay(hour * 60 + minute)
717 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
719 Convert a datetime into a minute number (of the day). Note that
720 this ignores the date part of the datetime and only uses the time
723 >>> d = string_to_datetime(
724 ... "2021/09/10 11:24:51AM-0700",
727 >>> datetime_to_minute_number(d)
731 return minute_number(dt.hour, dt.minute)
734 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
736 Convert a datetime.time into a minute number.
738 >>> t = datetime.time(5, 15)
739 >>> time_to_minute_number(t)
743 return minute_number(t.hour, t.minute)
746 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
748 Convert minute number from start of day into hour:minute am/pm
751 >>> minute_number_to_time_string(315)
754 >>> minute_number_to_time_string(684)
758 hour = minute_num // 60
759 minute = minute_num % 60
768 return f"{hour:2}:{minute:02}{ampm}"
771 def parse_duration(duration: str) -> int:
773 Parse a duration in string form into a delta seconds.
775 >>> parse_duration('15 days, 2 hours')
778 >>> parse_duration('15d 2h')
781 >>> parse_duration('100s')
784 >>> parse_duration('3min 2sec')
788 if duration.isdigit():
791 m = re.search(r'(\d+) *d[ays]*', duration)
793 seconds += int(m.group(1)) * 60 * 60 * 24
794 m = re.search(r'(\d+) *h[ours]*', duration)
796 seconds += int(m.group(1)) * 60 * 60
797 m = re.search(r'(\d+) *m[inutes]*', duration)
799 seconds += int(m.group(1)) * 60
800 m = re.search(r'(\d+) *s[econds]*', duration)
802 seconds += int(m.group(1))
806 def describe_duration(seconds: int, *, include_seconds=False) -> str:
808 Describe a duration represented as a count of seconds nicely.
810 >>> describe_duration(182)
813 >>> describe_duration(182, include_seconds=True)
814 '3 minutes, and 2 seconds'
816 >>> describe_duration(100, include_seconds=True)
817 '1 minute, and 40 seconds'
819 describe_duration(1303200)
823 days = divmod(seconds, constants.SECONDS_PER_DAY)
824 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
825 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
829 descr = f"{int(days[0])} days, "
834 descr = descr + f"{int(hours[0])} hours, "
836 descr = descr + "1 hour, "
838 if not include_seconds and len(descr) > 0:
839 descr = descr + "and "
842 descr = descr + "1 minute"
844 descr = descr + f"{int(minutes[0])} minutes"
849 descr = descr + 'and '
852 descr = descr + '1 second'
854 descr = descr + f'{s} seconds'
858 def describe_timedelta(delta: datetime.timedelta) -> str:
860 Describe a duration represented by a timedelta object.
862 >>> d = datetime.timedelta(1, 600)
863 >>> describe_timedelta(d)
864 '1 day, and 10 minutes'
867 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
870 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
872 Describe a duration briefly.
874 >>> describe_duration_briefly(182)
877 >>> describe_duration_briefly(182, include_seconds=True)
880 >>> describe_duration_briefly(100, include_seconds=True)
883 describe_duration_briefly(1303200)
887 days = divmod(seconds, constants.SECONDS_PER_DAY)
888 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
889 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
893 descr = f'{int(days[0])}d '
895 descr = descr + f'{int(hours[0])}h '
896 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
897 descr = descr + f'{int(minutes[0])}m '
898 if minutes[1] > 0 and include_seconds:
899 descr = descr + f'{int(minutes[1])}s'
903 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
905 Describe a duration represented by a timedelta object.
907 >>> d = datetime.timedelta(1, 600)
908 >>> describe_timedelta_briefly(d)
912 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
915 if __name__ == '__main__':