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(
168 date: datetime.date, time: datetime.time
169 ) -> datetime.datetime:
171 Given a date and time, merge them and return a datetime.
174 >>> d = datetime.date(2021, 12, 25)
175 >>> t = datetime.time(12, 30, 0, 0)
176 >>> date_and_time_to_datetime(d, t)
177 datetime.datetime(2021, 12, 25, 12, 30)
180 return datetime.datetime(
191 def datetime_to_date_and_time(
192 dt: datetime.datetime,
193 ) -> Tuple[datetime.date, datetime.time]:
194 """Return the component date and time objects of a datetime.
197 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
198 >>> (d, t) = datetime_to_date_and_time(dt)
200 datetime.date(2021, 12, 25)
202 datetime.time(12, 30)
205 return (dt.date(), dt.timetz())
208 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
209 """Return the date part of a datetime.
212 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
213 >>> datetime_to_date(dt)
214 datetime.date(2021, 12, 25)
217 return datetime_to_date_and_time(dt)[0]
220 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
221 """Return the time part of a datetime.
224 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
225 >>> datetime_to_time(dt)
226 datetime.time(12, 30)
229 return datetime_to_date_and_time(dt)[1]
232 class TimeUnit(enum.Enum):
233 """An enum to represent units with which we can compute deltas."""
252 def is_valid(cls, value: Any):
253 if type(value) is int:
254 return value in cls._value2member_map_
255 elif type(value) is TimeUnit:
256 return value.value in cls._value2member_map_
257 elif type(value) is str:
258 return value in cls._member_names_
264 def n_timeunits_from_base(
265 count: int, unit: TimeUnit, base: datetime.datetime
266 ) -> datetime.datetime:
267 """Return a datetime that is N units before/after a base datetime.
268 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
269 years before base datetime, 13 minutes after base datetime, etc...
270 Note: to indicate before/after the base date, use a positive or
273 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
275 The next (1) Monday from the base datetime:
276 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
277 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
279 Ten (10) years after the base datetime:
280 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
281 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
283 Fifty (50) working days (M..F, not counting holidays) after base datetime:
284 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
285 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
287 Fifty (50) days (including weekends and holidays) after base datetime:
288 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
289 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
291 Fifty (50) months before (note negative count) base datetime:
292 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
293 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
295 Fifty (50) hours after base datetime:
296 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
297 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
299 Fifty (50) minutes before base datetime:
300 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
301 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
303 Fifty (50) seconds from base datetime:
304 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
305 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
307 Next month corner case -- it will try to make Feb 31, 2022 then count
309 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
310 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
311 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
313 Last month with the same corner case
314 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
315 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
316 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
319 assert TimeUnit.is_valid(unit)
324 if unit == TimeUnit.DAYS:
325 timedelta = datetime.timedelta(days=count)
326 return base + timedelta
329 elif unit == TimeUnit.HOURS:
330 timedelta = datetime.timedelta(hours=count)
331 return base + timedelta
333 # N minutes from base
334 elif unit == TimeUnit.MINUTES:
335 timedelta = datetime.timedelta(minutes=count)
336 return base + timedelta
338 # N seconds from base
339 elif unit == TimeUnit.SECONDS:
340 timedelta = datetime.timedelta(seconds=count)
341 return base + timedelta
343 # N workdays from base
344 elif unit == TimeUnit.WORKDAYS:
347 timedelta = datetime.timedelta(days=-1)
349 timedelta = datetime.timedelta(days=1)
350 skips = holidays.US(years=base.year).keys()
354 if base.year != old_year:
355 skips = holidays.US(years=base.year).keys()
358 and datetime.date(base.year, base.month, base.day) not in skips
364 elif unit == TimeUnit.WEEKS:
365 timedelta = datetime.timedelta(weeks=count)
366 base = base + timedelta
370 elif unit == TimeUnit.MONTHS:
371 month_term = count % 12
372 year_term = count // 12
373 new_month = base.month + month_term
377 new_year = base.year + year_term
381 ret = datetime.datetime(
397 elif unit == TimeUnit.YEARS:
398 new_year = base.year + count
399 return datetime.datetime(
421 raise ValueError(unit)
423 # N weekdays from base (e.g. 4 wednesdays from today)
424 direction = 1 if count > 0 else -1
426 timedelta = datetime.timedelta(days=direction)
430 if dow == unit.value and start != base:
434 base = base + timedelta
437 def get_format_string(
439 date_time_separator=" ",
440 include_timezone=True,
441 include_dayname=False,
442 use_month_abbrevs=False,
443 include_seconds=True,
444 include_fractional=False,
448 Helper to return a format string without looking up the documentation
451 >>> get_format_string()
452 '%Y/%m/%d %I:%M:%S%p%z'
454 >>> get_format_string(date_time_separator='@')
455 '%Y/%m/%d@%I:%M:%S%p%z'
457 >>> get_format_string(include_dayname=True)
458 '%a/%Y/%m/%d %I:%M:%S%p%z'
460 >>> get_format_string(include_dayname=True, twelve_hour=False)
461 '%a/%Y/%m/%d %H:%M:%S%z'
468 if use_month_abbrevs:
469 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
471 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
481 if include_fractional:
488 def datetime_to_string(
489 dt: datetime.datetime,
491 date_time_separator=" ",
492 include_timezone=True,
493 include_dayname=False,
494 use_month_abbrevs=False,
495 include_seconds=True,
496 include_fractional=False,
500 A nice way to convert a datetime into a string; arguably better than
501 just printing it and relying on it __repr__().
503 >>> d = string_to_datetime(
504 ... "2021/09/10 11:24:51AM-0700",
507 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
508 >>> datetime_to_string(d)
509 '2021/09/10 11:24:51AM-0700'
510 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
511 'Fri/2021/09/10 11:24AM-0700'
514 fstring = get_format_string(
515 date_time_separator=date_time_separator,
516 include_timezone=include_timezone,
517 include_dayname=include_dayname,
518 include_seconds=include_seconds,
519 include_fractional=include_fractional,
520 twelve_hour=twelve_hour,
522 return dt.strftime(fstring).strip()
525 def string_to_datetime(
528 date_time_separator=" ",
529 include_timezone=True,
530 include_dayname=False,
531 use_month_abbrevs=False,
532 include_seconds=True,
533 include_fractional=False,
535 ) -> Tuple[datetime.datetime, str]:
536 """A nice way to convert a string into a datetime. Returns both the
537 datetime and the format string used to parse it. Also consider
538 dateparse.dateparse_utils for a full parser alternative.
540 >>> d = string_to_datetime(
541 ... "2021/09/10 11:24:51AM-0700",
544 (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')
547 fstring = get_format_string(
548 date_time_separator=date_time_separator,
549 include_timezone=include_timezone,
550 include_dayname=include_dayname,
551 include_seconds=include_seconds,
552 include_fractional=include_fractional,
553 twelve_hour=twelve_hour,
555 return (datetime.datetime.strptime(txt, fstring), fstring)
558 def timestamp() -> str:
559 """Return a timestamp for right now in Pacific timezone."""
560 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
561 return datetime_to_string(ts, include_timezone=True)
565 dt: datetime.datetime,
567 include_seconds=True,
568 include_fractional=False,
569 include_timezone=False,
572 """A nice way to convert a datetime into a time (only) string.
573 This ignores the date part of the datetime.
575 >>> d = string_to_datetime(
576 ... "2021/09/10 11:24:51AM-0700",
579 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
581 >>> time_to_string(d)
584 >>> time_to_string(d, include_seconds=False)
587 >>> time_to_string(d, include_seconds=False, include_timezone=True)
601 if include_fractional:
605 return dt.strftime(fstring).strip()
608 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
609 """Convert a delta in seconds into a timedelta."""
610 return datetime.timedelta(seconds=seconds)
613 MinuteOfDay = NewType("MinuteOfDay", int)
616 def minute_number(hour: int, minute: int) -> MinuteOfDay:
618 Convert hour:minute into minute number from start of day.
620 >>> minute_number(0, 0)
623 >>> minute_number(9, 15)
626 >>> minute_number(23, 59)
630 return MinuteOfDay(hour * 60 + minute)
633 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
635 Convert a datetime into a minute number (of the day). Note that
636 this ignores the date part of the datetime and only uses the time
639 >>> d = string_to_datetime(
640 ... "2021/09/10 11:24:51AM-0700",
643 >>> datetime_to_minute_number(d)
647 return minute_number(dt.hour, dt.minute)
650 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
652 Convert a datetime.time into a minute number.
654 >>> t = datetime.time(5, 15)
655 >>> time_to_minute_number(t)
659 return minute_number(t.hour, t.minute)
662 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
664 Convert minute number from start of day into hour:minute am/pm
667 >>> minute_number_to_time_string(315)
670 >>> minute_number_to_time_string(684)
674 hour = minute_num // 60
675 minute = minute_num % 60
684 return f"{hour:2}:{minute:02}{ampm}"
687 def parse_duration(duration: str) -> int:
689 Parse a duration in string form into a delta seconds.
691 >>> parse_duration('15 days, 2 hours')
694 >>> parse_duration('15d 2h')
697 >>> parse_duration('100s')
700 >>> parse_duration('3min 2sec')
704 if duration.isdigit():
707 m = re.search(r'(\d+) *d[ays]*', duration)
709 seconds += int(m.group(1)) * 60 * 60 * 24
710 m = re.search(r'(\d+) *h[ours]*', duration)
712 seconds += int(m.group(1)) * 60 * 60
713 m = re.search(r'(\d+) *m[inutes]*', duration)
715 seconds += int(m.group(1)) * 60
716 m = re.search(r'(\d+) *s[econds]*', duration)
718 seconds += int(m.group(1))
722 def describe_duration(seconds: int, *, include_seconds=False) -> str:
724 Describe a duration represented as a count of seconds nicely.
726 >>> describe_duration(182)
729 >>> describe_duration(182, include_seconds=True)
730 '3 minutes, and 2 seconds'
732 >>> describe_duration(100, include_seconds=True)
733 '1 minute, and 40 seconds'
735 describe_duration(1303200)
739 days = divmod(seconds, constants.SECONDS_PER_DAY)
740 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
741 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
745 descr = f"{int(days[0])} days, "
750 descr = descr + f"{int(hours[0])} hours, "
752 descr = descr + "1 hour, "
754 if not include_seconds and len(descr) > 0:
755 descr = descr + "and "
758 descr = descr + "1 minute"
760 descr = descr + f"{int(minutes[0])} minutes"
765 descr = descr + 'and '
768 descr = descr + '1 second'
770 descr = descr + f'{s} seconds'
774 def describe_timedelta(delta: datetime.timedelta) -> str:
776 Describe a duration represented by a timedelta object.
778 >>> d = datetime.timedelta(1, 600)
779 >>> describe_timedelta(d)
780 '1 day, and 10 minutes'
783 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
786 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
788 Describe a duration briefly.
790 >>> describe_duration_briefly(182)
793 >>> describe_duration_briefly(182, include_seconds=True)
796 >>> describe_duration_briefly(100, include_seconds=True)
799 describe_duration_briefly(1303200)
803 days = divmod(seconds, constants.SECONDS_PER_DAY)
804 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
805 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
809 descr = f'{int(days[0])}d '
811 descr = descr + f'{int(hours[0])}h '
812 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
813 descr = descr + f'{int(minutes[0])}m '
814 if minutes[1] > 0 and include_seconds:
815 descr = descr + f'{int(minutes[1])}s'
819 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
821 Describe a duration represented by a timedelta object.
823 >>> d = datetime.timedelta(1, 600)
824 >>> describe_timedelta_briefly(d)
828 return describe_duration_briefly(
829 int(delta.total_seconds())
830 ) # Note: drops milliseconds
833 if __name__ == '__main__':