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())
31 dt.tzinfo is not None and
32 dt.tzinfo.utcoffset(dt) is not None
36 def is_timezone_naive(dt: datetime.datetime) -> bool:
37 return not is_timezone_aware(dt)
40 def replace_timezone(dt: datetime.datetime,
41 tz: datetime.tzinfo) -> datetime.datetime:
43 Replaces the timezone on a datetime object directly (leaving
44 the year, month, day, hour, minute, second, micro, etc... alone).
45 Note: this changes the instant to which this dt refers.
47 >>> from pytz import UTC
49 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
52 >>> o = replace_timezone(d, UTC)
53 >>> o.tzinfo.tzname(o)
59 return datetime.datetime(
60 dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond,
65 def replace_time_timezone(t: datetime.time,
66 tz: datetime.tzinfo) -> datetime.time:
68 Replaces the timezone on a datetime.time directly without performing
71 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
75 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
80 return t.replace(tzinfo=tz)
83 def translate_timezone(dt: datetime.datetime,
84 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(
136 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
138 Given a time, returns that time as a datetime with a date component
139 set based on the current date. If the time passed is timezone aware,
140 the resulting datetime will also be (and will use the same tzinfo).
141 If the time is timezone naive, the datetime returned will be too.
143 >>> t = datetime.time(13, 14, 0)
144 >>> d = now_pacific().date()
145 >>> dt = time_to_datetime_today(t)
152 >>> dt.tzinfo == t.tzinfo
155 >>> dt.tzinfo == None
158 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
162 >>> dt = time_to_datetime_today(t)
163 >>> dt.tzinfo == None
169 return datetime.datetime.combine(now, time, tz)
172 def date_and_time_to_datetime(date: datetime.date,
173 time: datetime.time) -> datetime.datetime:
175 Given a date and time, merge them and return a datetime.
178 >>> d = datetime.date(2021, 12, 25)
179 >>> t = datetime.time(12, 30, 0, 0)
180 >>> date_and_time_to_datetime(d, t)
181 datetime.datetime(2021, 12, 25, 12, 30)
184 return datetime.datetime(
195 def datetime_to_date_and_time(
196 dt: datetime.datetime
197 ) -> Tuple[datetime.date, datetime.time]:
198 """Return the component date and time objects of a datetime.
201 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
202 >>> (d, t) = datetime_to_date_and_time(dt)
204 datetime.date(2021, 12, 25)
206 datetime.time(12, 30)
209 return (dt.date(), dt.timetz())
212 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
213 """Return the date part of a datetime.
216 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
217 >>> datetime_to_date(dt)
218 datetime.date(2021, 12, 25)
221 return datetime_to_date_and_time(dt)[0]
224 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
225 """Return the time part of a datetime.
228 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
229 >>> datetime_to_time(dt)
230 datetime.time(12, 30)
233 return datetime_to_date_and_time(dt)[1]
236 class TimeUnit(enum.Enum):
237 """An enum to represent units with which we can compute deltas."""
255 def is_valid(cls, value: Any):
256 if type(value) is int:
257 return value in cls._value2member_map_
258 elif type(value) is TimeUnit:
259 return value.value in cls._value2member_map_
260 elif type(value) is str:
261 return value in cls._member_names_
267 def n_timeunits_from_base(
270 base: datetime.datetime
271 ) -> datetime.datetime:
272 """Return a datetime that is N units before/after a base datetime.
273 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
274 years before base datetime, 13 minutes after base datetime, etc...
275 Note: to indicate before/after the base date, use a positive or
278 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
280 The next (1) Monday from the base datetime:
281 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
282 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
284 Ten (10) years after the base datetime:
285 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
286 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
288 Fifty (50) working days (M..F, not counting holidays) after base datetime:
289 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
290 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
292 Fifty (50) days (including weekends and holidays) after base datetime:
293 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
294 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
296 Fifty (50) months before (note negative count) base datetime:
297 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
298 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
300 Fifty (50) hours after base datetime:
301 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
302 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
304 Fifty (50) minutes before base datetime:
305 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
306 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
308 Fifty (50) seconds from base datetime:
309 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
310 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
313 assert TimeUnit.is_valid(unit)
318 if unit == TimeUnit.DAYS:
319 timedelta = datetime.timedelta(days=count)
320 return base + timedelta
323 elif unit == TimeUnit.HOURS:
324 timedelta = datetime.timedelta(hours=count)
325 return base + timedelta
327 # N minutes from base
328 elif unit == TimeUnit.MINUTES:
329 timedelta = datetime.timedelta(minutes=count)
330 return base + timedelta
332 # N seconds from base
333 elif unit == TimeUnit.SECONDS:
334 timedelta = datetime.timedelta(seconds=count)
335 return base + timedelta
337 # N workdays from base
338 elif unit == TimeUnit.WORKDAYS:
341 timedelta = datetime.timedelta(days=-1)
343 timedelta = datetime.timedelta(days=1)
344 skips = holidays.US(years=base.year).keys()
348 if base.year != old_year:
349 skips = holidays.US(years=base.year).keys()
351 base.weekday() < 5 and
352 datetime.date(base.year,
354 base.day) not in skips
360 elif unit == TimeUnit.WEEKS:
361 timedelta = datetime.timedelta(weeks=count)
362 base = base + timedelta
366 elif unit == TimeUnit.MONTHS:
367 month_term = count % 12
368 year_term = count // 12
369 new_month = base.month + month_term
373 new_year = base.year + year_term
374 return datetime.datetime(
386 elif unit == TimeUnit.YEARS:
387 new_year = base.year + count
388 return datetime.datetime(
399 if unit not in set([TimeUnit.MONDAYS,
406 raise ValueError(unit)
408 # N weekdays from base (e.g. 4 wednesdays from today)
409 direction = 1 if count > 0 else -1
411 timedelta = datetime.timedelta(days=direction)
415 if dow == unit.value and start != base:
419 base = base + timedelta
422 def get_format_string(
424 date_time_separator=" ",
425 include_timezone=True,
426 include_dayname=False,
427 use_month_abbrevs=False,
428 include_seconds=True,
429 include_fractional=False,
433 Helper to return a format string without looking up the documentation
436 >>> get_format_string()
437 '%Y/%m/%d %I:%M:%S%p%z'
439 >>> get_format_string(date_time_separator='@')
440 '%Y/%m/%d@%I:%M:%S%p%z'
442 >>> get_format_string(include_dayname=True)
443 '%a/%Y/%m/%d %I:%M:%S%p%z'
445 >>> get_format_string(include_dayname=True, twelve_hour=False)
446 '%a/%Y/%m/%d %H:%M:%S%z'
453 if use_month_abbrevs:
454 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
456 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
466 if include_fractional:
473 def datetime_to_string(
474 dt: datetime.datetime,
476 date_time_separator=" ",
477 include_timezone=True,
478 include_dayname=False,
479 use_month_abbrevs=False,
480 include_seconds=True,
481 include_fractional=False,
485 A nice way to convert a datetime into a string; arguably better than
486 just printing it and relying on it __repr__().
488 >>> d = string_to_datetime(
489 ... "2021/09/10 11:24:51AM-0700",
492 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
493 >>> datetime_to_string(d)
494 '2021/09/10 11:24:51AM-0700'
495 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
496 'Fri/2021/09/10 11:24AM-0700'
499 fstring = get_format_string(
500 date_time_separator=date_time_separator,
501 include_timezone=include_timezone,
502 include_dayname=include_dayname,
503 include_seconds=include_seconds,
504 include_fractional=include_fractional,
505 twelve_hour=twelve_hour)
506 return dt.strftime(fstring).strip()
509 def string_to_datetime(
512 date_time_separator=" ",
513 include_timezone=True,
514 include_dayname=False,
515 use_month_abbrevs=False,
516 include_seconds=True,
517 include_fractional=False,
519 ) -> Tuple[datetime.datetime, str]:
520 """A nice way to convert a string into a datetime. Returns both the
521 datetime and the format string used to parse it. Also consider
522 dateparse.dateparse_utils for a full parser alternative.
524 >>> d = string_to_datetime(
525 ... "2021/09/10 11:24:51AM-0700",
528 (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')
531 fstring = get_format_string(
532 date_time_separator=date_time_separator,
533 include_timezone=include_timezone,
534 include_dayname=include_dayname,
535 include_seconds=include_seconds,
536 include_fractional=include_fractional,
537 twelve_hour=twelve_hour)
539 datetime.datetime.strptime(txt, fstring),
544 def timestamp() -> str:
545 """Return a timestamp for right now in Pacific timezone."""
546 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
547 return datetime_to_string(ts, include_timezone=True)
551 dt: datetime.datetime,
553 include_seconds=True,
554 include_fractional=False,
555 include_timezone=False,
558 """A nice way to convert a datetime into a time (only) string.
559 This ignores the date part of the datetime.
561 >>> d = string_to_datetime(
562 ... "2021/09/10 11:24:51AM-0700",
565 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
567 >>> time_to_string(d)
570 >>> time_to_string(d, include_seconds=False)
573 >>> time_to_string(d, include_seconds=False, include_timezone=True)
587 if include_fractional:
591 return dt.strftime(fstring).strip()
594 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
595 """Convert a delta in seconds into a timedelta."""
596 return datetime.timedelta(seconds=seconds)
599 MinuteOfDay = NewType("MinuteOfDay", int)
602 def minute_number(hour: int, minute: int) -> MinuteOfDay:
604 Convert hour:minute into minute number from start of day.
606 >>> minute_number(0, 0)
609 >>> minute_number(9, 15)
612 >>> minute_number(23, 59)
616 return MinuteOfDay(hour * 60 + minute)
619 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
621 Convert a datetime into a minute number (of the day). Note that
622 this ignores the date part of the datetime and only uses the time
625 >>> d = string_to_datetime(
626 ... "2021/09/10 11:24:51AM-0700",
629 >>> datetime_to_minute_number(d)
633 return minute_number(dt.hour, dt.minute)
636 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
638 Convert a datetime.time into a minute number.
640 >>> t = datetime.time(5, 15)
641 >>> time_to_minute_number(t)
645 return minute_number(t.hour, t.minute)
648 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
650 Convert minute number from start of day into hour:minute am/pm
653 >>> minute_number_to_time_string(315)
656 >>> minute_number_to_time_string(684)
660 hour = minute_num // 60
661 minute = minute_num % 60
670 return f"{hour:2}:{minute:02}{ampm}"
673 def parse_duration(duration: str) -> int:
675 Parse a duration in string form into a delta seconds.
677 >>> parse_duration('15 days, 2 hours')
680 >>> parse_duration('15d 2h')
683 >>> parse_duration('100s')
686 >>> parse_duration('3min 2sec')
690 if duration.isdigit():
693 m = re.search(r'(\d+) *d[ays]*', duration)
695 seconds += int(m.group(1)) * 60 * 60 * 24
696 m = re.search(r'(\d+) *h[ours]*', duration)
698 seconds += int(m.group(1)) * 60 * 60
699 m = re.search(r'(\d+) *m[inutes]*', duration)
701 seconds += int(m.group(1)) * 60
702 m = re.search(r'(\d+) *s[econds]*', duration)
704 seconds += int(m.group(1))
708 def describe_duration(seconds: int, *, include_seconds = False) -> str:
710 Describe a duration represented as a count of seconds nicely.
712 >>> describe_duration(182)
715 >>> describe_duration(182, include_seconds=True)
716 '3 minutes, and 2 seconds'
718 >>> describe_duration(100, include_seconds=True)
719 '1 minute, and 40 seconds'
721 describe_duration(1303200)
725 days = divmod(seconds, constants.SECONDS_PER_DAY)
726 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
727 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
731 descr = f"{int(days[0])} days, "
736 descr = descr + f"{int(hours[0])} hours, "
738 descr = descr + "1 hour, "
740 if not include_seconds and len(descr) > 0:
741 descr = descr + "and "
744 descr = descr + "1 minute"
746 descr = descr + f"{int(minutes[0])} minutes"
751 descr = descr + 'and '
754 descr = descr + '1 second'
756 descr = descr + f'{s} seconds'
760 def describe_timedelta(delta: datetime.timedelta) -> str:
762 Describe a duration represented by a timedelta object.
764 >>> d = datetime.timedelta(1, 600)
765 >>> describe_timedelta(d)
766 '1 day, and 10 minutes'
769 return describe_duration(delta.total_seconds())
772 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
774 Describe a duration briefly.
776 >>> describe_duration_briefly(182)
779 >>> describe_duration_briefly(182, include_seconds=True)
782 >>> describe_duration_briefly(100, include_seconds=True)
785 describe_duration_briefly(1303200)
789 days = divmod(seconds, constants.SECONDS_PER_DAY)
790 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
791 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
795 descr = f'{int(days[0])}d '
797 descr = descr + f'{int(hours[0])}h '
798 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
799 descr = descr + f'{int(minutes[0])}m '
800 if minutes[1] > 0 and include_seconds:
801 descr = descr + f'{int(minutes[1])}s'
805 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
807 Describe a duration represented by a timedelta object.
809 >>> d = datetime.timedelta(1, 600)
810 >>> describe_timedelta_briefly(d)
814 return describe_duration_briefly(delta.total_seconds())
817 if __name__ == '__main__':