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)))
308 assert TimeUnit.is_valid(unit)
313 if unit == TimeUnit.DAYS:
314 timedelta = datetime.timedelta(days=count)
315 return base + timedelta
318 elif unit == TimeUnit.HOURS:
319 timedelta = datetime.timedelta(hours=count)
320 return base + timedelta
322 # N minutes from base
323 elif unit == TimeUnit.MINUTES:
324 timedelta = datetime.timedelta(minutes=count)
325 return base + timedelta
327 # N seconds from base
328 elif unit == TimeUnit.SECONDS:
329 timedelta = datetime.timedelta(seconds=count)
330 return base + timedelta
332 # N workdays from base
333 elif unit == TimeUnit.WORKDAYS:
336 timedelta = datetime.timedelta(days=-1)
338 timedelta = datetime.timedelta(days=1)
339 skips = holidays.US(years=base.year).keys()
343 if base.year != old_year:
344 skips = holidays.US(years=base.year).keys()
347 and datetime.date(base.year, base.month, base.day) not in skips
353 elif unit == TimeUnit.WEEKS:
354 timedelta = datetime.timedelta(weeks=count)
355 base = base + timedelta
359 elif unit == TimeUnit.MONTHS:
360 month_term = count % 12
361 year_term = count // 12
362 new_month = base.month + month_term
366 new_year = base.year + year_term
367 return datetime.datetime(
379 elif unit == TimeUnit.YEARS:
380 new_year = base.year + count
381 return datetime.datetime(
403 raise ValueError(unit)
405 # N weekdays from base (e.g. 4 wednesdays from today)
406 direction = 1 if count > 0 else -1
408 timedelta = datetime.timedelta(days=direction)
412 if dow == unit.value and start != base:
416 base = base + timedelta
419 def get_format_string(
421 date_time_separator=" ",
422 include_timezone=True,
423 include_dayname=False,
424 use_month_abbrevs=False,
425 include_seconds=True,
426 include_fractional=False,
430 Helper to return a format string without looking up the documentation
433 >>> get_format_string()
434 '%Y/%m/%d %I:%M:%S%p%z'
436 >>> get_format_string(date_time_separator='@')
437 '%Y/%m/%d@%I:%M:%S%p%z'
439 >>> get_format_string(include_dayname=True)
440 '%a/%Y/%m/%d %I:%M:%S%p%z'
442 >>> get_format_string(include_dayname=True, twelve_hour=False)
443 '%a/%Y/%m/%d %H:%M:%S%z'
450 if use_month_abbrevs:
451 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
453 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
463 if include_fractional:
470 def datetime_to_string(
471 dt: datetime.datetime,
473 date_time_separator=" ",
474 include_timezone=True,
475 include_dayname=False,
476 use_month_abbrevs=False,
477 include_seconds=True,
478 include_fractional=False,
482 A nice way to convert a datetime into a string; arguably better than
483 just printing it and relying on it __repr__().
485 >>> d = string_to_datetime(
486 ... "2021/09/10 11:24:51AM-0700",
489 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
490 >>> datetime_to_string(d)
491 '2021/09/10 11:24:51AM-0700'
492 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
493 'Fri/2021/09/10 11:24AM-0700'
496 fstring = get_format_string(
497 date_time_separator=date_time_separator,
498 include_timezone=include_timezone,
499 include_dayname=include_dayname,
500 include_seconds=include_seconds,
501 include_fractional=include_fractional,
502 twelve_hour=twelve_hour,
504 return dt.strftime(fstring).strip()
507 def string_to_datetime(
510 date_time_separator=" ",
511 include_timezone=True,
512 include_dayname=False,
513 use_month_abbrevs=False,
514 include_seconds=True,
515 include_fractional=False,
517 ) -> Tuple[datetime.datetime, str]:
518 """A nice way to convert a string into a datetime. Returns both the
519 datetime and the format string used to parse it. Also consider
520 dateparse.dateparse_utils for a full parser alternative.
522 >>> d = string_to_datetime(
523 ... "2021/09/10 11:24:51AM-0700",
526 (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')
529 fstring = get_format_string(
530 date_time_separator=date_time_separator,
531 include_timezone=include_timezone,
532 include_dayname=include_dayname,
533 include_seconds=include_seconds,
534 include_fractional=include_fractional,
535 twelve_hour=twelve_hour,
537 return (datetime.datetime.strptime(txt, fstring), fstring)
540 def timestamp() -> str:
541 """Return a timestamp for right now in Pacific timezone."""
542 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
543 return datetime_to_string(ts, include_timezone=True)
547 dt: datetime.datetime,
549 include_seconds=True,
550 include_fractional=False,
551 include_timezone=False,
554 """A nice way to convert a datetime into a time (only) string.
555 This ignores the date part of the datetime.
557 >>> d = string_to_datetime(
558 ... "2021/09/10 11:24:51AM-0700",
561 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
563 >>> time_to_string(d)
566 >>> time_to_string(d, include_seconds=False)
569 >>> time_to_string(d, include_seconds=False, include_timezone=True)
583 if include_fractional:
587 return dt.strftime(fstring).strip()
590 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
591 """Convert a delta in seconds into a timedelta."""
592 return datetime.timedelta(seconds=seconds)
595 MinuteOfDay = NewType("MinuteOfDay", int)
598 def minute_number(hour: int, minute: int) -> MinuteOfDay:
600 Convert hour:minute into minute number from start of day.
602 >>> minute_number(0, 0)
605 >>> minute_number(9, 15)
608 >>> minute_number(23, 59)
612 return MinuteOfDay(hour * 60 + minute)
615 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
617 Convert a datetime into a minute number (of the day). Note that
618 this ignores the date part of the datetime and only uses the time
621 >>> d = string_to_datetime(
622 ... "2021/09/10 11:24:51AM-0700",
625 >>> datetime_to_minute_number(d)
629 return minute_number(dt.hour, dt.minute)
632 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
634 Convert a datetime.time into a minute number.
636 >>> t = datetime.time(5, 15)
637 >>> time_to_minute_number(t)
641 return minute_number(t.hour, t.minute)
644 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
646 Convert minute number from start of day into hour:minute am/pm
649 >>> minute_number_to_time_string(315)
652 >>> minute_number_to_time_string(684)
656 hour = minute_num // 60
657 minute = minute_num % 60
666 return f"{hour:2}:{minute:02}{ampm}"
669 def parse_duration(duration: str) -> int:
671 Parse a duration in string form into a delta seconds.
673 >>> parse_duration('15 days, 2 hours')
676 >>> parse_duration('15d 2h')
679 >>> parse_duration('100s')
682 >>> parse_duration('3min 2sec')
686 if duration.isdigit():
689 m = re.search(r'(\d+) *d[ays]*', duration)
691 seconds += int(m.group(1)) * 60 * 60 * 24
692 m = re.search(r'(\d+) *h[ours]*', duration)
694 seconds += int(m.group(1)) * 60 * 60
695 m = re.search(r'(\d+) *m[inutes]*', duration)
697 seconds += int(m.group(1)) * 60
698 m = re.search(r'(\d+) *s[econds]*', duration)
700 seconds += int(m.group(1))
704 def describe_duration(seconds: int, *, include_seconds=False) -> str:
706 Describe a duration represented as a count of seconds nicely.
708 >>> describe_duration(182)
711 >>> describe_duration(182, include_seconds=True)
712 '3 minutes, and 2 seconds'
714 >>> describe_duration(100, include_seconds=True)
715 '1 minute, and 40 seconds'
717 describe_duration(1303200)
721 days = divmod(seconds, constants.SECONDS_PER_DAY)
722 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
723 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
727 descr = f"{int(days[0])} days, "
732 descr = descr + f"{int(hours[0])} hours, "
734 descr = descr + "1 hour, "
736 if not include_seconds and len(descr) > 0:
737 descr = descr + "and "
740 descr = descr + "1 minute"
742 descr = descr + f"{int(minutes[0])} minutes"
747 descr = descr + 'and '
750 descr = descr + '1 second'
752 descr = descr + f'{s} seconds'
756 def describe_timedelta(delta: datetime.timedelta) -> str:
758 Describe a duration represented by a timedelta object.
760 >>> d = datetime.timedelta(1, 600)
761 >>> describe_timedelta(d)
762 '1 day, and 10 minutes'
765 return describe_duration(delta.total_seconds())
768 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
770 Describe a duration briefly.
772 >>> describe_duration_briefly(182)
775 >>> describe_duration_briefly(182, include_seconds=True)
778 >>> describe_duration_briefly(100, include_seconds=True)
781 describe_duration_briefly(1303200)
785 days = divmod(seconds, constants.SECONDS_PER_DAY)
786 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
787 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
791 descr = f'{int(days[0])}d '
793 descr = descr + f'{int(hours[0])}h '
794 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
795 descr = descr + f'{int(minutes[0])}m '
796 if minutes[1] > 0 and include_seconds:
797 descr = descr + f'{int(minutes[1])}s'
801 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
803 Describe a duration represented by a timedelta object.
805 >>> d = datetime.timedelta(1, 600)
806 >>> describe_timedelta_briefly(d)
810 return describe_duration_briefly(delta.total_seconds())
813 if __name__ == '__main__':