3 """Utilities related to dates and times and datetimes."""
9 from typing import Any, NewType, 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 return not is_timezone_aware(dt)
37 def replace_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
39 Replaces the timezone on a datetime object directly (leaving
40 the year, month, day, hour, minute, second, micro, etc... alone).
41 Note: this changes the instant to which this dt refers.
43 >>> from pytz import UTC
45 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
48 >>> o = replace_timezone(d, UTC)
49 >>> o.tzinfo.tzname(o)
55 return datetime.datetime(
67 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
69 Replaces the timezone on a datetime.time directly without performing
72 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
76 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
81 return t.replace(tzinfo=tz)
84 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
86 Translates dt into a different timezone by adjusting the year, month,
87 day, hour, minute, second, micro, etc... appropriately. The returned
88 dt is the same instant in another timezone.
90 >>> from pytz import UTC
92 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
95 >>> o = translate_timezone(d, UTC)
96 >>> o.tzinfo.tzname(o)
102 return dt.replace(tzinfo=None).astimezone(tz=tz)
105 def now() -> datetime.datetime:
107 What time is it? Result returned in UTC
109 return datetime.datetime.now()
112 def now_pacific() -> datetime.datetime:
114 What time is it? Result in US/Pacific time (PST/PDT)
116 return datetime.datetime.now(pytz.timezone("US/Pacific"))
119 def date_to_datetime(date: datetime.date) -> datetime.datetime:
121 Given a date, return a datetime with hour/min/sec zero (midnight)
124 >>> date_to_datetime(datetime.date(2021, 12, 25))
125 datetime.datetime(2021, 12, 25, 0, 0)
128 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
131 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
133 Given a time, returns that time as a datetime with a date component
134 set based on the current date. If the time passed is timezone aware,
135 the resulting datetime will also be (and will use the same tzinfo).
136 If the time is timezone naive, the datetime returned will be too.
138 >>> t = datetime.time(13, 14, 0)
139 >>> d = now_pacific().date()
140 >>> dt = time_to_datetime_today(t)
147 >>> dt.tzinfo == t.tzinfo
150 >>> dt.tzinfo == None
153 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
157 >>> dt = time_to_datetime_today(t)
158 >>> dt.tzinfo == None
164 return datetime.datetime.combine(now, time, tz)
167 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
169 Given a date and time, merge them and return a datetime.
172 >>> d = datetime.date(2021, 12, 25)
173 >>> t = datetime.time(12, 30, 0, 0)
174 >>> date_and_time_to_datetime(d, t)
175 datetime.datetime(2021, 12, 25, 12, 30)
178 return datetime.datetime(
189 def datetime_to_date_and_time(
190 dt: datetime.datetime,
191 ) -> Tuple[datetime.date, datetime.time]:
192 """Return the component date and time objects of a datetime.
195 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
196 >>> (d, t) = datetime_to_date_and_time(dt)
198 datetime.date(2021, 12, 25)
200 datetime.time(12, 30)
203 return (dt.date(), dt.timetz())
206 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
207 """Return the date part of a datetime.
210 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
211 >>> datetime_to_date(dt)
212 datetime.date(2021, 12, 25)
215 return datetime_to_date_and_time(dt)[0]
218 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
219 """Return the time part of a datetime.
222 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
223 >>> datetime_to_time(dt)
224 datetime.time(12, 30)
227 return datetime_to_date_and_time(dt)[1]
230 class TimeUnit(enum.IntEnum):
231 """An enum to represent units with which we can compute deltas."""
250 def is_valid(cls, value: Any):
251 if type(value) is int:
252 return value in cls._value2member_map_
253 elif type(value) is TimeUnit:
254 return value.value in cls._value2member_map_
255 elif type(value) is str:
256 return value in cls._member_names_
262 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
263 """Return a datetime that is N units before/after a base datetime.
264 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
265 years before base datetime, 13 minutes after base datetime, etc...
266 Note: to indicate before/after the base date, use a positive or
269 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
271 The next (1) Monday from the base datetime:
272 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
273 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
275 Ten (10) years after the base datetime:
276 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
277 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
279 Fifty (50) working days (M..F, not counting holidays) after base datetime:
280 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
281 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
283 Fifty (50) days (including weekends and holidays) after base datetime:
284 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
285 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
287 Fifty (50) months before (note negative count) base datetime:
288 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
289 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
291 Fifty (50) hours after base datetime:
292 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
293 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
295 Fifty (50) minutes before base datetime:
296 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
297 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
299 Fifty (50) seconds from base datetime:
300 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
301 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
303 Next month corner case -- it will try to make Feb 31, 2022 then count
305 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
306 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
307 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
309 Last month with the same corner case
310 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
311 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
312 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
315 assert TimeUnit.is_valid(unit)
320 if unit == TimeUnit.DAYS:
321 timedelta = datetime.timedelta(days=count)
322 return base + timedelta
325 elif unit == TimeUnit.HOURS:
326 timedelta = datetime.timedelta(hours=count)
327 return base + timedelta
329 # N minutes from base
330 elif unit == TimeUnit.MINUTES:
331 timedelta = datetime.timedelta(minutes=count)
332 return base + timedelta
334 # N seconds from base
335 elif unit == TimeUnit.SECONDS:
336 timedelta = datetime.timedelta(seconds=count)
337 return base + timedelta
339 # N workdays from base
340 elif unit == TimeUnit.WORKDAYS:
343 timedelta = datetime.timedelta(days=-1)
345 timedelta = datetime.timedelta(days=1)
346 skips = holidays.US(years=base.year).keys()
350 if base.year != old_year:
351 skips = holidays.US(years=base.year).keys()
352 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
357 elif unit == TimeUnit.WEEKS:
358 timedelta = datetime.timedelta(weeks=count)
359 base = base + timedelta
363 elif unit == TimeUnit.MONTHS:
364 month_term = count % 12
365 year_term = count // 12
366 new_month = base.month + month_term
370 new_year = base.year + year_term
374 ret = datetime.datetime(
390 elif unit == TimeUnit.YEARS:
391 new_year = base.year + count
392 return datetime.datetime(
414 raise ValueError(unit)
416 # N weekdays from base (e.g. 4 wednesdays from today)
417 direction = 1 if count > 0 else -1
419 timedelta = datetime.timedelta(days=direction)
423 if dow == unit.value and start != base:
427 base = base + timedelta
430 def get_format_string(
432 date_time_separator=" ",
433 include_timezone=True,
434 include_dayname=False,
435 use_month_abbrevs=False,
436 include_seconds=True,
437 include_fractional=False,
441 Helper to return a format string without looking up the documentation
444 >>> get_format_string()
445 '%Y/%m/%d %I:%M:%S%p%z'
447 >>> get_format_string(date_time_separator='@')
448 '%Y/%m/%d@%I:%M:%S%p%z'
450 >>> get_format_string(include_dayname=True)
451 '%a/%Y/%m/%d %I:%M:%S%p%z'
453 >>> get_format_string(include_dayname=True, twelve_hour=False)
454 '%a/%Y/%m/%d %H:%M:%S%z'
461 if use_month_abbrevs:
462 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
464 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
474 if include_fractional:
481 def datetime_to_string(
482 dt: datetime.datetime,
484 date_time_separator=" ",
485 include_timezone=True,
486 include_dayname=False,
487 use_month_abbrevs=False,
488 include_seconds=True,
489 include_fractional=False,
493 A nice way to convert a datetime into a string; arguably better than
494 just printing it and relying on it __repr__().
496 >>> d = string_to_datetime(
497 ... "2021/09/10 11:24:51AM-0700",
500 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
501 >>> datetime_to_string(d)
502 '2021/09/10 11:24:51AM-0700'
503 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
504 'Fri/2021/09/10 11:24AM-0700'
507 fstring = get_format_string(
508 date_time_separator=date_time_separator,
509 include_timezone=include_timezone,
510 include_dayname=include_dayname,
511 include_seconds=include_seconds,
512 include_fractional=include_fractional,
513 twelve_hour=twelve_hour,
515 return dt.strftime(fstring).strip()
518 def string_to_datetime(
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,
528 ) -> Tuple[datetime.datetime, str]:
529 """A nice way to convert a string into a datetime. Returns both the
530 datetime and the format string used to parse it. Also consider
531 dateparse.dateparse_utils for a full parser alternative.
533 >>> d = string_to_datetime(
534 ... "2021/09/10 11:24:51AM-0700",
537 (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')
540 fstring = get_format_string(
541 date_time_separator=date_time_separator,
542 include_timezone=include_timezone,
543 include_dayname=include_dayname,
544 include_seconds=include_seconds,
545 include_fractional=include_fractional,
546 twelve_hour=twelve_hour,
548 return (datetime.datetime.strptime(txt, fstring), fstring)
551 def timestamp() -> str:
552 """Return a timestamp for right now in Pacific timezone."""
553 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
554 return datetime_to_string(ts, include_timezone=True)
558 dt: datetime.datetime,
560 include_seconds=True,
561 include_fractional=False,
562 include_timezone=False,
565 """A nice way to convert a datetime into a time (only) string.
566 This ignores the date part of the datetime.
568 >>> d = string_to_datetime(
569 ... "2021/09/10 11:24:51AM-0700",
572 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
574 >>> time_to_string(d)
577 >>> time_to_string(d, include_seconds=False)
580 >>> time_to_string(d, include_seconds=False, include_timezone=True)
594 if include_fractional:
598 return dt.strftime(fstring).strip()
601 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
602 """Convert a delta in seconds into a timedelta."""
603 return datetime.timedelta(seconds=seconds)
606 MinuteOfDay = NewType("MinuteOfDay", int)
609 def minute_number(hour: int, minute: int) -> MinuteOfDay:
611 Convert hour:minute into minute number from start of day.
613 >>> minute_number(0, 0)
616 >>> minute_number(9, 15)
619 >>> minute_number(23, 59)
623 return MinuteOfDay(hour * 60 + minute)
626 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
628 Convert a datetime into a minute number (of the day). Note that
629 this ignores the date part of the datetime and only uses the time
632 >>> d = string_to_datetime(
633 ... "2021/09/10 11:24:51AM-0700",
636 >>> datetime_to_minute_number(d)
640 return minute_number(dt.hour, dt.minute)
643 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
645 Convert a datetime.time into a minute number.
647 >>> t = datetime.time(5, 15)
648 >>> time_to_minute_number(t)
652 return minute_number(t.hour, t.minute)
655 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
657 Convert minute number from start of day into hour:minute am/pm
660 >>> minute_number_to_time_string(315)
663 >>> minute_number_to_time_string(684)
667 hour = minute_num // 60
668 minute = minute_num % 60
677 return f"{hour:2}:{minute:02}{ampm}"
680 def parse_duration(duration: str) -> int:
682 Parse a duration in string form into a delta seconds.
684 >>> parse_duration('15 days, 2 hours')
687 >>> parse_duration('15d 2h')
690 >>> parse_duration('100s')
693 >>> parse_duration('3min 2sec')
697 if duration.isdigit():
700 m = re.search(r'(\d+) *d[ays]*', duration)
702 seconds += int(m.group(1)) * 60 * 60 * 24
703 m = re.search(r'(\d+) *h[ours]*', duration)
705 seconds += int(m.group(1)) * 60 * 60
706 m = re.search(r'(\d+) *m[inutes]*', duration)
708 seconds += int(m.group(1)) * 60
709 m = re.search(r'(\d+) *s[econds]*', duration)
711 seconds += int(m.group(1))
715 def describe_duration(seconds: int, *, include_seconds=False) -> str:
717 Describe a duration represented as a count of seconds nicely.
719 >>> describe_duration(182)
722 >>> describe_duration(182, include_seconds=True)
723 '3 minutes, and 2 seconds'
725 >>> describe_duration(100, include_seconds=True)
726 '1 minute, and 40 seconds'
728 describe_duration(1303200)
732 days = divmod(seconds, constants.SECONDS_PER_DAY)
733 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
734 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
738 descr = f"{int(days[0])} days, "
743 descr = descr + f"{int(hours[0])} hours, "
745 descr = descr + "1 hour, "
747 if not include_seconds and len(descr) > 0:
748 descr = descr + "and "
751 descr = descr + "1 minute"
753 descr = descr + f"{int(minutes[0])} minutes"
758 descr = descr + 'and '
761 descr = descr + '1 second'
763 descr = descr + f'{s} seconds'
767 def describe_timedelta(delta: datetime.timedelta) -> str:
769 Describe a duration represented by a timedelta object.
771 >>> d = datetime.timedelta(1, 600)
772 >>> describe_timedelta(d)
773 '1 day, and 10 minutes'
776 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
779 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
781 Describe a duration briefly.
783 >>> describe_duration_briefly(182)
786 >>> describe_duration_briefly(182, include_seconds=True)
789 >>> describe_duration_briefly(100, include_seconds=True)
792 describe_duration_briefly(1303200)
796 days = divmod(seconds, constants.SECONDS_PER_DAY)
797 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
798 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
802 descr = f'{int(days[0])}d '
804 descr = descr + f'{int(hours[0])}h '
805 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
806 descr = descr + f'{int(minutes[0])}m '
807 if minutes[1] > 0 and include_seconds:
808 descr = descr + f'{int(minutes[1])}s'
812 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
814 Describe a duration represented by a timedelta object.
816 >>> d = datetime.timedelta(1, 600)
817 >>> describe_timedelta_briefly(d)
821 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
824 if __name__ == '__main__':