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
163 return datetime.datetime.combine(now_pacific(), time, tz)
166 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
168 Given a date and time, merge them and return a datetime.
171 >>> d = datetime.date(2021, 12, 25)
172 >>> t = datetime.time(12, 30, 0, 0)
173 >>> date_and_time_to_datetime(d, t)
174 datetime.datetime(2021, 12, 25, 12, 30)
177 return datetime.datetime(
188 def datetime_to_date_and_time(
189 dt: datetime.datetime,
190 ) -> Tuple[datetime.date, datetime.time]:
191 """Return the component date and time objects of a datetime.
194 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
195 >>> (d, t) = datetime_to_date_and_time(dt)
197 datetime.date(2021, 12, 25)
199 datetime.time(12, 30)
202 return (dt.date(), dt.timetz())
205 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
206 """Return the date part of a datetime.
209 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
210 >>> datetime_to_date(dt)
211 datetime.date(2021, 12, 25)
214 return datetime_to_date_and_time(dt)[0]
217 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
218 """Return the time part of a datetime.
221 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
222 >>> datetime_to_time(dt)
223 datetime.time(12, 30)
226 return datetime_to_date_and_time(dt)[1]
229 class TimeUnit(enum.IntEnum):
230 """An enum to represent units with which we can compute deltas."""
249 def is_valid(cls, value: Any):
250 if isinstance(value, int):
251 return cls(value) is not None
252 elif isinstance(value, TimeUnit):
253 return cls(value.value) is not None
254 elif isinstance(value, str):
255 return cls.__members__[value] is not None
261 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
262 """Return a datetime that is N units before/after a base datetime.
263 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
264 years before base datetime, 13 minutes after base datetime, etc...
265 Note: to indicate before/after the base date, use a positive or
268 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
270 The next (1) Monday from the base datetime:
271 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
272 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
274 Ten (10) years after the base datetime:
275 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
276 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
278 Fifty (50) working days (M..F, not counting holidays) after base datetime:
279 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
280 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
282 Fifty (50) days (including weekends and holidays) after base datetime:
283 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
284 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
286 Fifty (50) months before (note negative count) base datetime:
287 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
288 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
290 Fifty (50) hours after base datetime:
291 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
292 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
294 Fifty (50) minutes before base datetime:
295 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
296 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
298 Fifty (50) seconds from base datetime:
299 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
300 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
302 Next month corner case -- it will try to make Feb 31, 2022 then count
304 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
305 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
306 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
308 Last month with the same corner case
309 >>> base = string_to_datetime("2022/03/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)))
314 assert TimeUnit.is_valid(unit)
319 if unit == TimeUnit.DAYS:
320 timedelta = datetime.timedelta(days=count)
321 return base + timedelta
324 elif unit == TimeUnit.HOURS:
325 timedelta = datetime.timedelta(hours=count)
326 return base + timedelta
328 # N minutes from base
329 elif unit == TimeUnit.MINUTES:
330 timedelta = datetime.timedelta(minutes=count)
331 return base + timedelta
333 # N seconds from base
334 elif unit == TimeUnit.SECONDS:
335 timedelta = datetime.timedelta(seconds=count)
336 return base + timedelta
338 # N workdays from base
339 elif unit == TimeUnit.WORKDAYS:
342 timedelta = datetime.timedelta(days=-1)
344 timedelta = datetime.timedelta(days=1)
345 skips = holidays.US(years=base.year).keys()
349 if base.year != old_year:
350 skips = holidays.US(years=base.year).keys()
351 if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
356 elif unit == TimeUnit.WEEKS:
357 timedelta = datetime.timedelta(weeks=count)
358 base = base + timedelta
362 elif unit == TimeUnit.MONTHS:
363 month_term = count % 12
364 year_term = count // 12
365 new_month = base.month + month_term
369 new_year = base.year + year_term
373 ret = datetime.datetime(
389 elif unit == TimeUnit.YEARS:
390 new_year = base.year + count
391 return datetime.datetime(
413 raise ValueError(unit)
415 # N weekdays from base (e.g. 4 wednesdays from today)
416 direction = 1 if count > 0 else -1
418 timedelta = datetime.timedelta(days=direction)
422 if dow == unit.value and start != base:
426 base = base + timedelta
429 def get_format_string(
431 date_time_separator=" ",
432 include_timezone=True,
433 include_dayname=False,
434 use_month_abbrevs=False,
435 include_seconds=True,
436 include_fractional=False,
440 Helper to return a format string without looking up the documentation
443 >>> get_format_string()
444 '%Y/%m/%d %I:%M:%S%p%z'
446 >>> get_format_string(date_time_separator='@')
447 '%Y/%m/%d@%I:%M:%S%p%z'
449 >>> get_format_string(include_dayname=True)
450 '%a/%Y/%m/%d %I:%M:%S%p%z'
452 >>> get_format_string(include_dayname=True, twelve_hour=False)
453 '%a/%Y/%m/%d %H:%M:%S%z'
460 if use_month_abbrevs:
461 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
463 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
473 if include_fractional:
480 def datetime_to_string(
481 dt: datetime.datetime,
483 date_time_separator=" ",
484 include_timezone=True,
485 include_dayname=False,
486 use_month_abbrevs=False,
487 include_seconds=True,
488 include_fractional=False,
492 A nice way to convert a datetime into a string; arguably better than
493 just printing it and relying on it __repr__().
495 >>> d = string_to_datetime(
496 ... "2021/09/10 11:24:51AM-0700",
499 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
500 >>> datetime_to_string(d)
501 '2021/09/10 11:24:51AM-0700'
502 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
503 'Fri/2021/09/10 11:24AM-0700'
506 fstring = get_format_string(
507 date_time_separator=date_time_separator,
508 include_timezone=include_timezone,
509 include_dayname=include_dayname,
510 use_month_abbrevs=use_month_abbrevs,
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 use_month_abbrevs=use_month_abbrevs,
545 include_seconds=include_seconds,
546 include_fractional=include_fractional,
547 twelve_hour=twelve_hour,
549 return (datetime.datetime.strptime(txt, fstring), fstring)
552 def timestamp() -> str:
553 """Return a timestamp for right now in Pacific timezone."""
554 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
555 return datetime_to_string(ts, include_timezone=True)
559 dt: datetime.datetime,
561 include_seconds=True,
562 include_fractional=False,
563 include_timezone=False,
566 """A nice way to convert a datetime into a time (only) string.
567 This ignores the date part of the datetime.
569 >>> d = string_to_datetime(
570 ... "2021/09/10 11:24:51AM-0700",
573 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
575 >>> time_to_string(d)
578 >>> time_to_string(d, include_seconds=False)
581 >>> time_to_string(d, include_seconds=False, include_timezone=True)
595 if include_fractional:
599 return dt.strftime(fstring).strip()
602 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
603 """Convert a delta in seconds into a timedelta."""
604 return datetime.timedelta(seconds=seconds)
607 MinuteOfDay = NewType("MinuteOfDay", int)
610 def minute_number(hour: int, minute: int) -> MinuteOfDay:
612 Convert hour:minute into minute number from start of day.
614 >>> minute_number(0, 0)
617 >>> minute_number(9, 15)
620 >>> minute_number(23, 59)
624 return MinuteOfDay(hour * 60 + minute)
627 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
629 Convert a datetime into a minute number (of the day). Note that
630 this ignores the date part of the datetime and only uses the time
633 >>> d = string_to_datetime(
634 ... "2021/09/10 11:24:51AM-0700",
637 >>> datetime_to_minute_number(d)
641 return minute_number(dt.hour, dt.minute)
644 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
646 Convert a datetime.time into a minute number.
648 >>> t = datetime.time(5, 15)
649 >>> time_to_minute_number(t)
653 return minute_number(t.hour, t.minute)
656 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
658 Convert minute number from start of day into hour:minute am/pm
661 >>> minute_number_to_time_string(315)
664 >>> minute_number_to_time_string(684)
668 hour = minute_num // 60
669 minute = minute_num % 60
678 return f"{hour:2}:{minute:02}{ampm}"
681 def parse_duration(duration: str) -> int:
683 Parse a duration in string form into a delta seconds.
685 >>> parse_duration('15 days, 2 hours')
688 >>> parse_duration('15d 2h')
691 >>> parse_duration('100s')
694 >>> parse_duration('3min 2sec')
698 if duration.isdigit():
701 m = re.search(r'(\d+) *d[ays]*', duration)
703 seconds += int(m.group(1)) * 60 * 60 * 24
704 m = re.search(r'(\d+) *h[ours]*', duration)
706 seconds += int(m.group(1)) * 60 * 60
707 m = re.search(r'(\d+) *m[inutes]*', duration)
709 seconds += int(m.group(1)) * 60
710 m = re.search(r'(\d+) *s[econds]*', duration)
712 seconds += int(m.group(1))
716 def describe_duration(seconds: int, *, include_seconds=False) -> str:
718 Describe a duration represented as a count of seconds nicely.
720 >>> describe_duration(182)
723 >>> describe_duration(182, include_seconds=True)
724 '3 minutes, and 2 seconds'
726 >>> describe_duration(100, include_seconds=True)
727 '1 minute, and 40 seconds'
729 describe_duration(1303200)
733 days = divmod(seconds, constants.SECONDS_PER_DAY)
734 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
735 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
739 descr = f"{int(days[0])} days, "
744 descr = descr + f"{int(hours[0])} hours, "
746 descr = descr + "1 hour, "
748 if not include_seconds and len(descr) > 0:
749 descr = descr + "and "
752 descr = descr + "1 minute"
754 descr = descr + f"{int(minutes[0])} minutes"
759 descr = descr + 'and '
762 descr = descr + '1 second'
764 descr = descr + f'{s} seconds'
768 def describe_timedelta(delta: datetime.timedelta) -> str:
770 Describe a duration represented by a timedelta object.
772 >>> d = datetime.timedelta(1, 600)
773 >>> describe_timedelta(d)
774 '1 day, and 10 minutes'
777 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
780 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
782 Describe a duration briefly.
784 >>> describe_duration_briefly(182)
787 >>> describe_duration_briefly(182, include_seconds=True)
790 >>> describe_duration_briefly(100, include_seconds=True)
793 describe_duration_briefly(1303200)
797 days = divmod(seconds, constants.SECONDS_PER_DAY)
798 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
799 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
803 descr = f'{int(days[0])}d '
805 descr = descr + f'{int(hours[0])}h '
806 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
807 descr = descr + f'{int(minutes[0])}m '
808 if minutes[1] > 0 and include_seconds:
809 descr = descr + f'{int(minutes[1])}s'
813 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
815 Describe a duration represented by a timedelta object.
817 >>> d = datetime.timedelta(1, 600)
818 >>> describe_timedelta_briefly(d)
822 return describe_duration_briefly(int(delta.total_seconds())) # Note: drops milliseconds
825 if __name__ == '__main__':