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 replace_timezone(dt: datetime.datetime,
20 tz: datetime.tzinfo) -> datetime.datetime:
22 Replaces the timezone on a datetime object.
24 >>> from pytz import UTC
26 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
28 >>> o = replace_timezone(d, UTC)
29 >>> o.tzinfo.tzname(o)
33 return dt.replace(tzinfo=None).astimezone(tz=tz)
36 def now() -> datetime.datetime:
38 What time is it? Result returned in UTC
40 return datetime.datetime.now()
43 def now_pacific() -> datetime.datetime:
45 What time is it? Result in US/Pacifit time (PST/PDT)
47 return replace_timezone(now(), pytz.timezone("US/Pacific"))
50 def date_to_datetime(date: datetime.date) -> datetime.datetime:
52 Given a date, return a datetime with hour/min/sec zero (midnight)
55 >>> date_to_datetime(datetime.date(2021, 12, 25))
56 datetime.datetime(2021, 12, 25, 0, 0)
59 return datetime.datetime(
67 def date_and_time_to_datetime(date: datetime.date,
68 time: datetime.time) -> datetime.datetime:
70 Given a date and time, merge them and return a datetime.
73 >>> d = datetime.date(2021, 12, 25)
74 >>> t = datetime.time(12, 30, 0, 0)
75 >>> date_and_time_to_datetime(d, t)
76 datetime.datetime(2021, 12, 25, 12, 30)
79 return datetime.datetime(
90 def datetime_to_date_and_time(
92 ) -> Tuple[datetime.date, datetime.time]:
93 """Return the component date and time objects of a datetime.
96 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
97 >>> (d, t) = datetime_to_date_and_time(dt)
99 datetime.date(2021, 12, 25)
101 datetime.time(12, 30)
104 return (dt.date(), dt.timetz())
107 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
108 """Return the date part of a datetime.
111 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
112 >>> datetime_to_date(dt)
113 datetime.date(2021, 12, 25)
116 return datetime_to_date_and_time(dt)[0]
119 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
120 """Return the time part of a datetime.
123 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
124 >>> datetime_to_time(dt)
125 datetime.time(12, 30)
128 return datetime_to_date_and_time(dt)[1]
131 class TimeUnit(enum.Enum):
132 """An enum to represent units with which we can compute deltas."""
150 def is_valid(cls, value: Any):
151 if type(value) is int:
152 return value in cls._value2member_map_
153 elif type(value) is TimeUnit:
154 return value.value in cls._value2member_map_
155 elif type(value) is str:
156 return value in cls._member_names_
162 def n_timeunits_from_base(
165 base: datetime.datetime
166 ) -> datetime.datetime:
167 """Return a datetime that is N units before/after a base datetime.
168 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
169 years before base datetime, 13 minutes after base datetime, etc...
170 Note: to indicate before/after the base date, use a positive or
173 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
175 The next (1) Monday from the base datetime:
176 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
177 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
179 Ten (10) years after the base datetime:
180 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
181 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
183 Fifty (50) working days (M..F, not counting holidays) after base datetime:
184 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
185 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
187 Fifty (50) days (including weekends and holidays) after base datetime:
188 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
189 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
191 Fifty (50) months before (note negative count) base datetime:
192 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
193 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
195 Fifty (50) hours after base datetime:
196 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
197 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
199 Fifty (50) minutes before base datetime:
200 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
201 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
203 Fifty (50) seconds from base datetime:
204 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
205 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
208 assert TimeUnit.is_valid(unit)
213 if unit == TimeUnit.DAYS:
214 timedelta = datetime.timedelta(days=count)
215 return base + timedelta
218 elif unit == TimeUnit.HOURS:
219 timedelta = datetime.timedelta(hours=count)
220 return base + timedelta
222 # N minutes from base
223 elif unit == TimeUnit.MINUTES:
224 timedelta = datetime.timedelta(minutes=count)
225 return base + timedelta
227 # N seconds from base
228 elif unit == TimeUnit.SECONDS:
229 timedelta = datetime.timedelta(seconds=count)
230 return base + timedelta
232 # N workdays from base
233 elif unit == TimeUnit.WORKDAYS:
236 timedelta = datetime.timedelta(days=-1)
238 timedelta = datetime.timedelta(days=1)
239 skips = holidays.US(years=base.year).keys()
243 if base.year != old_year:
244 skips = holidays.US(years=base.year).keys()
246 base.weekday() < 5 and
247 datetime.date(base.year,
249 base.day) not in skips
255 elif unit == TimeUnit.WEEKS:
256 timedelta = datetime.timedelta(weeks=count)
257 base = base + timedelta
261 elif unit == TimeUnit.MONTHS:
262 month_term = count % 12
263 year_term = count // 12
264 new_month = base.month + month_term
268 new_year = base.year + year_term
269 return datetime.datetime(
281 elif unit == TimeUnit.YEARS:
282 new_year = base.year + count
283 return datetime.datetime(
294 if unit not in set([TimeUnit.MONDAYS,
301 raise ValueError(unit)
303 # N weekdays from base (e.g. 4 wednesdays from today)
304 direction = 1 if count > 0 else -1
306 timedelta = datetime.timedelta(days=direction)
310 if dow == unit.value and start != base:
314 base = base + timedelta
317 def get_format_string(
319 date_time_separator=" ",
320 include_timezone=True,
321 include_dayname=False,
322 use_month_abbrevs=False,
323 include_seconds=True,
324 include_fractional=False,
328 Helper to return a format string without looking up the documentation
331 >>> get_format_string()
332 '%Y/%m/%d %I:%M:%S%p%z'
334 >>> get_format_string(date_time_separator='@')
335 '%Y/%m/%d@%I:%M:%S%p%z'
337 >>> get_format_string(include_dayname=True)
338 '%a/%Y/%m/%d %I:%M:%S%p%z'
340 >>> get_format_string(include_dayname=True, twelve_hour=False)
341 '%a/%Y/%m/%d %H:%M:%S%z'
348 if use_month_abbrevs:
349 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
351 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
361 if include_fractional:
368 def datetime_to_string(
369 dt: datetime.datetime,
371 date_time_separator=" ",
372 include_timezone=True,
373 include_dayname=False,
374 use_month_abbrevs=False,
375 include_seconds=True,
376 include_fractional=False,
380 A nice way to convert a datetime into a string; arguably better than
381 just printing it and relying on it __repr__().
383 >>> d = string_to_datetime(
384 ... "2021/09/10 11:24:51AM-0700",
387 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
388 >>> datetime_to_string(d)
389 '2021/09/10 11:24:51AM-0700'
390 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
391 'Fri/2021/09/10 11:24AM-0700'
394 fstring = get_format_string(
395 date_time_separator=date_time_separator,
396 include_timezone=include_timezone,
397 include_dayname=include_dayname,
398 include_seconds=include_seconds,
399 include_fractional=include_fractional,
400 twelve_hour=twelve_hour)
401 return dt.strftime(fstring).strip()
404 def string_to_datetime(
407 date_time_separator=" ",
408 include_timezone=True,
409 include_dayname=False,
410 use_month_abbrevs=False,
411 include_seconds=True,
412 include_fractional=False,
414 ) -> Tuple[datetime.datetime, str]:
415 """A nice way to convert a string into a datetime. Returns both the
416 datetime and the format string used to parse it. Also consider
417 dateparse.dateparse_utils for a full parser alternative.
419 >>> d = string_to_datetime(
420 ... "2021/09/10 11:24:51AM-0700",
423 (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')
426 fstring = get_format_string(
427 date_time_separator=date_time_separator,
428 include_timezone=include_timezone,
429 include_dayname=include_dayname,
430 include_seconds=include_seconds,
431 include_fractional=include_fractional,
432 twelve_hour=twelve_hour)
434 datetime.datetime.strptime(txt, fstring),
439 def timestamp() -> str:
440 """Return a timestamp for right now in Pacific timezone."""
441 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
442 return datetime_to_string(ts, include_timezone=True)
446 dt: datetime.datetime,
448 include_seconds=True,
449 include_fractional=False,
450 include_timezone=False,
453 """A nice way to convert a datetime into a time (only) string.
454 This ignores the date part of the datetime.
456 >>> d = string_to_datetime(
457 ... "2021/09/10 11:24:51AM-0700",
460 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
462 >>> time_to_string(d)
465 >>> time_to_string(d, include_seconds=False)
468 >>> time_to_string(d, include_seconds=False, include_timezone=True)
482 if include_fractional:
486 return dt.strftime(fstring).strip()
489 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
490 """Convert a delta in seconds into a timedelta."""
491 return datetime.timedelta(seconds=seconds)
494 MinuteOfDay = NewType("MinuteOfDay", int)
497 def minute_number(hour: int, minute: int) -> MinuteOfDay:
499 Convert hour:minute into minute number from start of day.
501 >>> minute_number(0, 0)
504 >>> minute_number(9, 15)
507 >>> minute_number(23, 59)
511 return MinuteOfDay(hour * 60 + minute)
514 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
516 Convert a datetime into a minute number (of the day). Note that
517 this ignores the date part of the datetime and only uses the time
520 >>> d = string_to_datetime(
521 ... "2021/09/10 11:24:51AM-0700",
524 >>> datetime_to_minute_number(d)
528 return minute_number(dt.hour, dt.minute)
531 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
533 Convert a datetime.time into a minute number.
535 >>> t = datetime.time(5, 15)
536 >>> time_to_minute_number(t)
540 return minute_number(t.hour, t.minute)
543 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
545 Convert minute number from start of day into hour:minute am/pm
548 >>> minute_number_to_time_string(315)
551 >>> minute_number_to_time_string(684)
555 hour = minute_num // 60
556 minute = minute_num % 60
565 return f"{hour:2}:{minute:02}{ampm}"
568 def parse_duration(duration: str) -> int:
570 Parse a duration in string form into a delta seconds.
572 >>> parse_duration('15 days, 2 hours')
575 >>> parse_duration('15d 2h')
578 >>> parse_duration('100s')
581 >>> parse_duration('3min 2sec')
585 if duration.isdigit():
588 m = re.search(r'(\d+) *d[ays]*', duration)
590 seconds += int(m.group(1)) * 60 * 60 * 24
591 m = re.search(r'(\d+) *h[ours]*', duration)
593 seconds += int(m.group(1)) * 60 * 60
594 m = re.search(r'(\d+) *m[inutes]*', duration)
596 seconds += int(m.group(1)) * 60
597 m = re.search(r'(\d+) *s[econds]*', duration)
599 seconds += int(m.group(1))
603 def describe_duration(seconds: int, *, include_seconds = False) -> str:
605 Describe a duration represented as a count of seconds nicely.
607 >>> describe_duration(182)
610 >>> describe_duration(182, include_seconds=True)
611 '3 minutes, and 2 seconds'
613 >>> describe_duration(100, include_seconds=True)
614 '1 minute, and 40 seconds'
616 describe_duration(1303200)
620 days = divmod(seconds, constants.SECONDS_PER_DAY)
621 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
622 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
626 descr = f"{int(days[0])} days, "
631 descr = descr + f"{int(hours[0])} hours, "
633 descr = descr + "1 hour, "
635 if not include_seconds and len(descr) > 0:
636 descr = descr + "and "
639 descr = descr + "1 minute"
641 descr = descr + f"{int(minutes[0])} minutes"
646 descr = descr + 'and '
649 descr = descr + '1 second'
651 descr = descr + f'{s} seconds'
655 def describe_timedelta(delta: datetime.timedelta) -> str:
657 Describe a duration represented by a timedelta object.
659 >>> d = datetime.timedelta(1, 600)
660 >>> describe_timedelta(d)
661 '1 day, and 10 minutes'
664 return describe_duration(delta.total_seconds())
667 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
669 Describe a duration briefly.
671 >>> describe_duration_briefly(182)
674 >>> describe_duration_briefly(182, include_seconds=True)
677 >>> describe_duration_briefly(100, include_seconds=True)
680 describe_duration_briefly(1303200)
684 days = divmod(seconds, constants.SECONDS_PER_DAY)
685 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
686 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
690 descr = f'{int(days[0])}d '
692 descr = descr + f'{int(hours[0])}h '
693 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
694 descr = descr + f'{int(minutes[0])}m '
695 if minutes[1] > 0 and include_seconds:
696 descr = descr + f'{int(minutes[1])}s'
700 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
702 Describe a duration represented by a timedelta object.
704 >>> d = datetime.timedelta(1, 600)
705 >>> describe_timedelta_briefly(d)
709 return describe_duration_briefly(delta.total_seconds())
712 if __name__ == '__main__':