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 translate_timezone(dt: datetime.datetime,
66 tz: datetime.tzinfo) -> datetime.datetime:
68 Translates dt into a different timezone by adjusting the year, month,
69 day, hour, minute, second, micro, etc... appropriately. The returned
70 dt is the same instant in another timezone.
72 >>> from pytz import UTC
74 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
77 >>> o = translate_timezone(d, UTC)
78 >>> o.tzinfo.tzname(o)
84 return dt.replace(tzinfo=None).astimezone(tz=tz)
87 def now() -> datetime.datetime:
89 What time is it? Result returned in UTC
91 return datetime.datetime.now()
94 def now_pacific() -> datetime.datetime:
96 What time is it? Result in US/Pacific time (PST/PDT)
98 return datetime.datetime.now(pytz.timezone("US/Pacific"))
101 def date_to_datetime(date: datetime.date) -> datetime.datetime:
103 Given a date, return a datetime with hour/min/sec zero (midnight)
106 >>> date_to_datetime(datetime.date(2021, 12, 25))
107 datetime.datetime(2021, 12, 25, 0, 0)
110 return datetime.datetime(
118 def date_and_time_to_datetime(date: datetime.date,
119 time: datetime.time) -> datetime.datetime:
121 Given a date and time, merge them and return a datetime.
124 >>> d = datetime.date(2021, 12, 25)
125 >>> t = datetime.time(12, 30, 0, 0)
126 >>> date_and_time_to_datetime(d, t)
127 datetime.datetime(2021, 12, 25, 12, 30)
130 return datetime.datetime(
141 def datetime_to_date_and_time(
142 dt: datetime.datetime
143 ) -> Tuple[datetime.date, datetime.time]:
144 """Return the component date and time objects of a datetime.
147 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
148 >>> (d, t) = datetime_to_date_and_time(dt)
150 datetime.date(2021, 12, 25)
152 datetime.time(12, 30)
155 return (dt.date(), dt.timetz())
158 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
159 """Return the date part of a datetime.
162 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
163 >>> datetime_to_date(dt)
164 datetime.date(2021, 12, 25)
167 return datetime_to_date_and_time(dt)[0]
170 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
171 """Return the time part of a datetime.
174 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
175 >>> datetime_to_time(dt)
176 datetime.time(12, 30)
179 return datetime_to_date_and_time(dt)[1]
182 class TimeUnit(enum.Enum):
183 """An enum to represent units with which we can compute deltas."""
201 def is_valid(cls, value: Any):
202 if type(value) is int:
203 return value in cls._value2member_map_
204 elif type(value) is TimeUnit:
205 return value.value in cls._value2member_map_
206 elif type(value) is str:
207 return value in cls._member_names_
213 def n_timeunits_from_base(
216 base: datetime.datetime
217 ) -> datetime.datetime:
218 """Return a datetime that is N units before/after a base datetime.
219 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
220 years before base datetime, 13 minutes after base datetime, etc...
221 Note: to indicate before/after the base date, use a positive or
224 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
226 The next (1) Monday from the base datetime:
227 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
228 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
230 Ten (10) years after the base datetime:
231 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
232 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
234 Fifty (50) working days (M..F, not counting holidays) after base datetime:
235 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
236 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
238 Fifty (50) days (including weekends and holidays) after base datetime:
239 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
240 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
242 Fifty (50) months before (note negative count) base datetime:
243 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
244 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
246 Fifty (50) hours after base datetime:
247 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
248 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
250 Fifty (50) minutes before base datetime:
251 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
252 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
254 Fifty (50) seconds from base datetime:
255 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
256 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
259 assert TimeUnit.is_valid(unit)
264 if unit == TimeUnit.DAYS:
265 timedelta = datetime.timedelta(days=count)
266 return base + timedelta
269 elif unit == TimeUnit.HOURS:
270 timedelta = datetime.timedelta(hours=count)
271 return base + timedelta
273 # N minutes from base
274 elif unit == TimeUnit.MINUTES:
275 timedelta = datetime.timedelta(minutes=count)
276 return base + timedelta
278 # N seconds from base
279 elif unit == TimeUnit.SECONDS:
280 timedelta = datetime.timedelta(seconds=count)
281 return base + timedelta
283 # N workdays from base
284 elif unit == TimeUnit.WORKDAYS:
287 timedelta = datetime.timedelta(days=-1)
289 timedelta = datetime.timedelta(days=1)
290 skips = holidays.US(years=base.year).keys()
294 if base.year != old_year:
295 skips = holidays.US(years=base.year).keys()
297 base.weekday() < 5 and
298 datetime.date(base.year,
300 base.day) not in skips
306 elif unit == TimeUnit.WEEKS:
307 timedelta = datetime.timedelta(weeks=count)
308 base = base + timedelta
312 elif unit == TimeUnit.MONTHS:
313 month_term = count % 12
314 year_term = count // 12
315 new_month = base.month + month_term
319 new_year = base.year + year_term
320 return datetime.datetime(
332 elif unit == TimeUnit.YEARS:
333 new_year = base.year + count
334 return datetime.datetime(
345 if unit not in set([TimeUnit.MONDAYS,
352 raise ValueError(unit)
354 # N weekdays from base (e.g. 4 wednesdays from today)
355 direction = 1 if count > 0 else -1
357 timedelta = datetime.timedelta(days=direction)
361 if dow == unit.value and start != base:
365 base = base + timedelta
368 def get_format_string(
370 date_time_separator=" ",
371 include_timezone=True,
372 include_dayname=False,
373 use_month_abbrevs=False,
374 include_seconds=True,
375 include_fractional=False,
379 Helper to return a format string without looking up the documentation
382 >>> get_format_string()
383 '%Y/%m/%d %I:%M:%S%p%z'
385 >>> get_format_string(date_time_separator='@')
386 '%Y/%m/%d@%I:%M:%S%p%z'
388 >>> get_format_string(include_dayname=True)
389 '%a/%Y/%m/%d %I:%M:%S%p%z'
391 >>> get_format_string(include_dayname=True, twelve_hour=False)
392 '%a/%Y/%m/%d %H:%M:%S%z'
399 if use_month_abbrevs:
400 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
402 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
412 if include_fractional:
419 def datetime_to_string(
420 dt: datetime.datetime,
422 date_time_separator=" ",
423 include_timezone=True,
424 include_dayname=False,
425 use_month_abbrevs=False,
426 include_seconds=True,
427 include_fractional=False,
431 A nice way to convert a datetime into a string; arguably better than
432 just printing it and relying on it __repr__().
434 >>> d = string_to_datetime(
435 ... "2021/09/10 11:24:51AM-0700",
438 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
439 >>> datetime_to_string(d)
440 '2021/09/10 11:24:51AM-0700'
441 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
442 'Fri/2021/09/10 11:24AM-0700'
445 fstring = get_format_string(
446 date_time_separator=date_time_separator,
447 include_timezone=include_timezone,
448 include_dayname=include_dayname,
449 include_seconds=include_seconds,
450 include_fractional=include_fractional,
451 twelve_hour=twelve_hour)
452 return dt.strftime(fstring).strip()
455 def string_to_datetime(
458 date_time_separator=" ",
459 include_timezone=True,
460 include_dayname=False,
461 use_month_abbrevs=False,
462 include_seconds=True,
463 include_fractional=False,
465 ) -> Tuple[datetime.datetime, str]:
466 """A nice way to convert a string into a datetime. Returns both the
467 datetime and the format string used to parse it. Also consider
468 dateparse.dateparse_utils for a full parser alternative.
470 >>> d = string_to_datetime(
471 ... "2021/09/10 11:24:51AM-0700",
474 (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')
477 fstring = get_format_string(
478 date_time_separator=date_time_separator,
479 include_timezone=include_timezone,
480 include_dayname=include_dayname,
481 include_seconds=include_seconds,
482 include_fractional=include_fractional,
483 twelve_hour=twelve_hour)
485 datetime.datetime.strptime(txt, fstring),
490 def timestamp() -> str:
491 """Return a timestamp for right now in Pacific timezone."""
492 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
493 return datetime_to_string(ts, include_timezone=True)
497 dt: datetime.datetime,
499 include_seconds=True,
500 include_fractional=False,
501 include_timezone=False,
504 """A nice way to convert a datetime into a time (only) string.
505 This ignores the date part of the datetime.
507 >>> d = string_to_datetime(
508 ... "2021/09/10 11:24:51AM-0700",
511 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
513 >>> time_to_string(d)
516 >>> time_to_string(d, include_seconds=False)
519 >>> time_to_string(d, include_seconds=False, include_timezone=True)
533 if include_fractional:
537 return dt.strftime(fstring).strip()
540 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
541 """Convert a delta in seconds into a timedelta."""
542 return datetime.timedelta(seconds=seconds)
545 MinuteOfDay = NewType("MinuteOfDay", int)
548 def minute_number(hour: int, minute: int) -> MinuteOfDay:
550 Convert hour:minute into minute number from start of day.
552 >>> minute_number(0, 0)
555 >>> minute_number(9, 15)
558 >>> minute_number(23, 59)
562 return MinuteOfDay(hour * 60 + minute)
565 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
567 Convert a datetime into a minute number (of the day). Note that
568 this ignores the date part of the datetime and only uses the time
571 >>> d = string_to_datetime(
572 ... "2021/09/10 11:24:51AM-0700",
575 >>> datetime_to_minute_number(d)
579 return minute_number(dt.hour, dt.minute)
582 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
584 Convert a datetime.time into a minute number.
586 >>> t = datetime.time(5, 15)
587 >>> time_to_minute_number(t)
591 return minute_number(t.hour, t.minute)
594 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
596 Convert minute number from start of day into hour:minute am/pm
599 >>> minute_number_to_time_string(315)
602 >>> minute_number_to_time_string(684)
606 hour = minute_num // 60
607 minute = minute_num % 60
616 return f"{hour:2}:{minute:02}{ampm}"
619 def parse_duration(duration: str) -> int:
621 Parse a duration in string form into a delta seconds.
623 >>> parse_duration('15 days, 2 hours')
626 >>> parse_duration('15d 2h')
629 >>> parse_duration('100s')
632 >>> parse_duration('3min 2sec')
636 if duration.isdigit():
639 m = re.search(r'(\d+) *d[ays]*', duration)
641 seconds += int(m.group(1)) * 60 * 60 * 24
642 m = re.search(r'(\d+) *h[ours]*', duration)
644 seconds += int(m.group(1)) * 60 * 60
645 m = re.search(r'(\d+) *m[inutes]*', duration)
647 seconds += int(m.group(1)) * 60
648 m = re.search(r'(\d+) *s[econds]*', duration)
650 seconds += int(m.group(1))
654 def describe_duration(seconds: int, *, include_seconds = False) -> str:
656 Describe a duration represented as a count of seconds nicely.
658 >>> describe_duration(182)
661 >>> describe_duration(182, include_seconds=True)
662 '3 minutes, and 2 seconds'
664 >>> describe_duration(100, include_seconds=True)
665 '1 minute, and 40 seconds'
667 describe_duration(1303200)
671 days = divmod(seconds, constants.SECONDS_PER_DAY)
672 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
673 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
677 descr = f"{int(days[0])} days, "
682 descr = descr + f"{int(hours[0])} hours, "
684 descr = descr + "1 hour, "
686 if not include_seconds and len(descr) > 0:
687 descr = descr + "and "
690 descr = descr + "1 minute"
692 descr = descr + f"{int(minutes[0])} minutes"
697 descr = descr + 'and '
700 descr = descr + '1 second'
702 descr = descr + f'{s} seconds'
706 def describe_timedelta(delta: datetime.timedelta) -> str:
708 Describe a duration represented by a timedelta object.
710 >>> d = datetime.timedelta(1, 600)
711 >>> describe_timedelta(d)
712 '1 day, and 10 minutes'
715 return describe_duration(delta.total_seconds())
718 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
720 Describe a duration briefly.
722 >>> describe_duration_briefly(182)
725 >>> describe_duration_briefly(182, include_seconds=True)
728 >>> describe_duration_briefly(100, include_seconds=True)
731 describe_duration_briefly(1303200)
735 days = divmod(seconds, constants.SECONDS_PER_DAY)
736 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
737 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
741 descr = f'{int(days[0])}d '
743 descr = descr + f'{int(hours[0])}h '
744 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
745 descr = descr + f'{int(minutes[0])}m '
746 if minutes[1] > 0 and include_seconds:
747 descr = descr + f'{int(minutes[1])}s'
751 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
753 Describe a duration represented by a timedelta object.
755 >>> d = datetime.timedelta(1, 600)
756 >>> describe_timedelta_briefly(d)
760 return describe_duration_briefly(delta.total_seconds())
763 if __name__ == '__main__':