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)
38 dt: datetime.datetime, tz: datetime.tzinfo
39 ) -> datetime.datetime:
41 Replaces the timezone on a datetime object directly (leaving
42 the year, month, day, hour, minute, second, micro, etc... alone).
43 Note: this changes the instant to which this dt refers.
45 >>> from pytz import UTC
47 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
50 >>> o = replace_timezone(d, UTC)
51 >>> o.tzinfo.tzname(o)
57 return datetime.datetime(
69 def replace_time_timezone(
70 t: datetime.time, tz: datetime.tzinfo
73 Replaces the timezone on a datetime.time directly without performing
76 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
80 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
85 return t.replace(tzinfo=tz)
88 def translate_timezone(
89 dt: datetime.datetime, tz: datetime.tzinfo
90 ) -> datetime.datetime:
92 Translates dt into a different timezone by adjusting the year, month,
93 day, hour, minute, second, micro, etc... appropriately. The returned
94 dt is the same instant in another timezone.
96 >>> from pytz import UTC
98 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
101 >>> o = translate_timezone(d, UTC)
102 >>> o.tzinfo.tzname(o)
108 return dt.replace(tzinfo=None).astimezone(tz=tz)
111 def now() -> datetime.datetime:
113 What time is it? Result returned in UTC
115 return datetime.datetime.now()
118 def now_pacific() -> datetime.datetime:
120 What time is it? Result in US/Pacific time (PST/PDT)
122 return datetime.datetime.now(pytz.timezone("US/Pacific"))
125 def date_to_datetime(date: datetime.date) -> datetime.datetime:
127 Given a date, return a datetime with hour/min/sec zero (midnight)
130 >>> date_to_datetime(datetime.date(2021, 12, 25))
131 datetime.datetime(2021, 12, 25, 0, 0)
134 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
137 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
139 Given a time, returns that time as a datetime with a date component
140 set based on the current date. If the time passed is timezone aware,
141 the resulting datetime will also be (and will use the same tzinfo).
142 If the time is timezone naive, the datetime returned will be too.
144 >>> t = datetime.time(13, 14, 0)
145 >>> d = now_pacific().date()
146 >>> dt = time_to_datetime_today(t)
153 >>> dt.tzinfo == t.tzinfo
156 >>> dt.tzinfo == None
159 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
163 >>> dt = time_to_datetime_today(t)
164 >>> dt.tzinfo == None
170 return datetime.datetime.combine(now, time, tz)
173 def date_and_time_to_datetime(
174 date: datetime.date, time: datetime.time
175 ) -> datetime.datetime:
177 Given a date and time, merge them and return a datetime.
180 >>> d = datetime.date(2021, 12, 25)
181 >>> t = datetime.time(12, 30, 0, 0)
182 >>> date_and_time_to_datetime(d, t)
183 datetime.datetime(2021, 12, 25, 12, 30)
186 return datetime.datetime(
197 def datetime_to_date_and_time(
198 dt: datetime.datetime,
199 ) -> Tuple[datetime.date, datetime.time]:
200 """Return the component date and time objects of a datetime.
203 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
204 >>> (d, t) = datetime_to_date_and_time(dt)
206 datetime.date(2021, 12, 25)
208 datetime.time(12, 30)
211 return (dt.date(), dt.timetz())
214 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
215 """Return the date part of a datetime.
218 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
219 >>> datetime_to_date(dt)
220 datetime.date(2021, 12, 25)
223 return datetime_to_date_and_time(dt)[0]
226 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
227 """Return the time part of a datetime.
230 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
231 >>> datetime_to_time(dt)
232 datetime.time(12, 30)
235 return datetime_to_date_and_time(dt)[1]
238 class TimeUnit(enum.Enum):
239 """An enum to represent units with which we can compute deltas."""
258 def is_valid(cls, value: Any):
259 if type(value) is int:
260 return value in cls._value2member_map_
261 elif type(value) is TimeUnit:
262 return value.value in cls._value2member_map_
263 elif type(value) is str:
264 return value in cls._member_names_
270 def n_timeunits_from_base(
271 count: int, unit: TimeUnit, base: datetime.datetime
272 ) -> datetime.datetime:
273 """Return a datetime that is N units before/after a base datetime.
274 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
275 years before base datetime, 13 minutes after base datetime, etc...
276 Note: to indicate before/after the base date, use a positive or
279 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
281 The next (1) Monday from the base datetime:
282 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
283 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
285 Ten (10) years after the base datetime:
286 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
287 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
289 Fifty (50) working days (M..F, not counting holidays) after base datetime:
290 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
291 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
293 Fifty (50) days (including weekends and holidays) after base datetime:
294 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
295 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
297 Fifty (50) months before (note negative count) base datetime:
298 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
299 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
301 Fifty (50) hours after base datetime:
302 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
303 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
305 Fifty (50) minutes before base datetime:
306 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
307 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
309 Fifty (50) seconds from base datetime:
310 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
311 datetime.datetime(2021, 9, 10, 11, 25, 41, 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()
353 and datetime.date(base.year, base.month, base.day) not in skips
359 elif unit == TimeUnit.WEEKS:
360 timedelta = datetime.timedelta(weeks=count)
361 base = base + timedelta
365 elif unit == TimeUnit.MONTHS:
366 month_term = count % 12
367 year_term = count // 12
368 new_month = base.month + month_term
372 new_year = base.year + year_term
373 return datetime.datetime(
385 elif unit == TimeUnit.YEARS:
386 new_year = base.year + count
387 return datetime.datetime(
409 raise ValueError(unit)
411 # N weekdays from base (e.g. 4 wednesdays from today)
412 direction = 1 if count > 0 else -1
414 timedelta = datetime.timedelta(days=direction)
418 if dow == unit.value and start != base:
422 base = base + timedelta
425 def get_format_string(
427 date_time_separator=" ",
428 include_timezone=True,
429 include_dayname=False,
430 use_month_abbrevs=False,
431 include_seconds=True,
432 include_fractional=False,
436 Helper to return a format string without looking up the documentation
439 >>> get_format_string()
440 '%Y/%m/%d %I:%M:%S%p%z'
442 >>> get_format_string(date_time_separator='@')
443 '%Y/%m/%d@%I:%M:%S%p%z'
445 >>> get_format_string(include_dayname=True)
446 '%a/%Y/%m/%d %I:%M:%S%p%z'
448 >>> get_format_string(include_dayname=True, twelve_hour=False)
449 '%a/%Y/%m/%d %H:%M:%S%z'
456 if use_month_abbrevs:
457 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
459 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
469 if include_fractional:
476 def datetime_to_string(
477 dt: datetime.datetime,
479 date_time_separator=" ",
480 include_timezone=True,
481 include_dayname=False,
482 use_month_abbrevs=False,
483 include_seconds=True,
484 include_fractional=False,
488 A nice way to convert a datetime into a string; arguably better than
489 just printing it and relying on it __repr__().
491 >>> d = string_to_datetime(
492 ... "2021/09/10 11:24:51AM-0700",
495 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
496 >>> datetime_to_string(d)
497 '2021/09/10 11:24:51AM-0700'
498 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
499 'Fri/2021/09/10 11:24AM-0700'
502 fstring = get_format_string(
503 date_time_separator=date_time_separator,
504 include_timezone=include_timezone,
505 include_dayname=include_dayname,
506 include_seconds=include_seconds,
507 include_fractional=include_fractional,
508 twelve_hour=twelve_hour,
510 return dt.strftime(fstring).strip()
513 def string_to_datetime(
516 date_time_separator=" ",
517 include_timezone=True,
518 include_dayname=False,
519 use_month_abbrevs=False,
520 include_seconds=True,
521 include_fractional=False,
523 ) -> Tuple[datetime.datetime, str]:
524 """A nice way to convert a string into a datetime. Returns both the
525 datetime and the format string used to parse it. Also consider
526 dateparse.dateparse_utils for a full parser alternative.
528 >>> d = string_to_datetime(
529 ... "2021/09/10 11:24:51AM-0700",
532 (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')
535 fstring = get_format_string(
536 date_time_separator=date_time_separator,
537 include_timezone=include_timezone,
538 include_dayname=include_dayname,
539 include_seconds=include_seconds,
540 include_fractional=include_fractional,
541 twelve_hour=twelve_hour,
543 return (datetime.datetime.strptime(txt, fstring), fstring)
546 def timestamp() -> str:
547 """Return a timestamp for right now in Pacific timezone."""
548 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
549 return datetime_to_string(ts, include_timezone=True)
553 dt: datetime.datetime,
555 include_seconds=True,
556 include_fractional=False,
557 include_timezone=False,
560 """A nice way to convert a datetime into a time (only) string.
561 This ignores the date part of the datetime.
563 >>> d = string_to_datetime(
564 ... "2021/09/10 11:24:51AM-0700",
567 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
569 >>> time_to_string(d)
572 >>> time_to_string(d, include_seconds=False)
575 >>> time_to_string(d, include_seconds=False, include_timezone=True)
589 if include_fractional:
593 return dt.strftime(fstring).strip()
596 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
597 """Convert a delta in seconds into a timedelta."""
598 return datetime.timedelta(seconds=seconds)
601 MinuteOfDay = NewType("MinuteOfDay", int)
604 def minute_number(hour: int, minute: int) -> MinuteOfDay:
606 Convert hour:minute into minute number from start of day.
608 >>> minute_number(0, 0)
611 >>> minute_number(9, 15)
614 >>> minute_number(23, 59)
618 return MinuteOfDay(hour * 60 + minute)
621 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
623 Convert a datetime into a minute number (of the day). Note that
624 this ignores the date part of the datetime and only uses the time
627 >>> d = string_to_datetime(
628 ... "2021/09/10 11:24:51AM-0700",
631 >>> datetime_to_minute_number(d)
635 return minute_number(dt.hour, dt.minute)
638 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
640 Convert a datetime.time into a minute number.
642 >>> t = datetime.time(5, 15)
643 >>> time_to_minute_number(t)
647 return minute_number(t.hour, t.minute)
650 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
652 Convert minute number from start of day into hour:minute am/pm
655 >>> minute_number_to_time_string(315)
658 >>> minute_number_to_time_string(684)
662 hour = minute_num // 60
663 minute = minute_num % 60
672 return f"{hour:2}:{minute:02}{ampm}"
675 def parse_duration(duration: str) -> int:
677 Parse a duration in string form into a delta seconds.
679 >>> parse_duration('15 days, 2 hours')
682 >>> parse_duration('15d 2h')
685 >>> parse_duration('100s')
688 >>> parse_duration('3min 2sec')
692 if duration.isdigit():
695 m = re.search(r'(\d+) *d[ays]*', duration)
697 seconds += int(m.group(1)) * 60 * 60 * 24
698 m = re.search(r'(\d+) *h[ours]*', duration)
700 seconds += int(m.group(1)) * 60 * 60
701 m = re.search(r'(\d+) *m[inutes]*', duration)
703 seconds += int(m.group(1)) * 60
704 m = re.search(r'(\d+) *s[econds]*', duration)
706 seconds += int(m.group(1))
710 def describe_duration(seconds: int, *, include_seconds=False) -> str:
712 Describe a duration represented as a count of seconds nicely.
714 >>> describe_duration(182)
717 >>> describe_duration(182, include_seconds=True)
718 '3 minutes, and 2 seconds'
720 >>> describe_duration(100, include_seconds=True)
721 '1 minute, and 40 seconds'
723 describe_duration(1303200)
727 days = divmod(seconds, constants.SECONDS_PER_DAY)
728 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
729 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
733 descr = f"{int(days[0])} days, "
738 descr = descr + f"{int(hours[0])} hours, "
740 descr = descr + "1 hour, "
742 if not include_seconds and len(descr) > 0:
743 descr = descr + "and "
746 descr = descr + "1 minute"
748 descr = descr + f"{int(minutes[0])} minutes"
753 descr = descr + 'and '
756 descr = descr + '1 second'
758 descr = descr + f'{s} seconds'
762 def describe_timedelta(delta: datetime.timedelta) -> str:
764 Describe a duration represented by a timedelta object.
766 >>> d = datetime.timedelta(1, 600)
767 >>> describe_timedelta(d)
768 '1 day, and 10 minutes'
771 return describe_duration(delta.total_seconds())
774 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
776 Describe a duration briefly.
778 >>> describe_duration_briefly(182)
781 >>> describe_duration_briefly(182, include_seconds=True)
784 >>> describe_duration_briefly(100, include_seconds=True)
787 describe_duration_briefly(1303200)
791 days = divmod(seconds, constants.SECONDS_PER_DAY)
792 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
793 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
797 descr = f'{int(days[0])}d '
799 descr = descr + f'{int(hours[0])}h '
800 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
801 descr = descr + f'{int(minutes[0])}m '
802 if minutes[1] > 0 and include_seconds:
803 descr = descr + f'{int(minutes[1])}s'
807 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
809 Describe a duration represented by a timedelta object.
811 >>> d = datetime.timedelta(1, 600)
812 >>> describe_timedelta_briefly(d)
816 return describe_duration_briefly(delta.total_seconds())
819 if __name__ == '__main__':