3 # © Copyright 2021-2022, Scott Gasch
5 """Utilities related to dates, times, and datetimes."""
11 from typing import Any, NewType, Optional, Tuple
13 import holidays # type: ignore
16 from pyutils.datetimez import constants
18 logger = logging.getLogger(__name__)
21 def is_timezone_aware(dt: datetime.datetime) -> bool:
22 """Returns true if the datetime argument is timezone aware or
25 See: https://docs.python.org/3/library/datetime.html
26 #determining-if-an-object-is-aware-or-naive
29 dt: The datetime object to check
31 >>> is_timezone_aware(datetime.datetime.now())
34 >>> is_timezone_aware(now_pacific())
38 return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
41 def is_timezone_naive(dt: datetime.datetime) -> bool:
42 """Inverse of is_timezone_aware -- returns true if the dt argument
45 See: https://docs.python.org/3/library/datetime.html
46 #determining-if-an-object-is-aware-or-naive
49 dt: The datetime object to check
51 >>> is_timezone_naive(datetime.datetime.now())
54 >>> is_timezone_naive(now_pacific())
58 return not is_timezone_aware(dt)
61 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
62 """Remove the timezone from a datetime.
66 This does not change the hours, minutes, seconds,
67 months, days, years, etc... Thus the instant to which this
68 timestamp refers will change. Silently ignores datetimes
69 which are already timezone naive.
71 >>> now = now_pacific()
72 >>> now.tzinfo == None
75 >>> dt = strip_timezone(now)
82 >>> dt.hour == now.hour
86 if is_timezone_naive(dt):
88 return replace_timezone(dt, None)
91 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
93 Adds a timezone to a timezone naive datetime. This does not
94 change the instant to which the timestamp refers. See also:
97 >>> now = datetime.datetime.now()
98 >>> is_timezone_aware(now)
101 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
102 >>> is_timezone_aware(now_pacific)
105 >>> now.hour == now_pacific.hour
107 >>> now.minute == now_pacific.minute
112 # This doesn't work, tz requires a timezone naive dt. Two options
114 # 1. Use strip_timezone and try again.
115 # 2. Replace the timezone on your dt object via replace_timezone.
116 # Be aware that this changes the instant to which the dt refers
117 # and, further, can introduce weirdness like UTC offsets that
118 # are weird (e.g. not an even multiple of an hour, etc...)
119 if is_timezone_aware(dt):
123 f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
124 + 'depending on the semantics you want. See the pydocs / code.'
126 return dt.replace(tzinfo=tz)
129 def replace_timezone(
130 dt: datetime.datetime, tz: Optional[datetime.tzinfo]
131 ) -> datetime.datetime:
132 """Replaces the timezone on a timezone aware datetime object directly
133 (leaving the year, month, day, hour, minute, second, micro,
136 Works with timezone aware and timezone naive dts but for the
137 latter it is probably better to use add_timezone or just create it
138 with a tz parameter. Using this can have weird side effects like
139 UTC offsets that are not an even multiple of an hour, etc...
143 This changes the instant to which this dt refers.
145 >>> from pytz import UTC
146 >>> d = now_pacific()
147 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
150 >>> o = replace_timezone(d, UTC)
151 >>> o.tzinfo.tzname(o)
157 if is_timezone_aware(dt):
159 '%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.',
162 return datetime.datetime(
174 return add_timezone(dt, tz)
179 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
180 """Replaces the timezone on a datetime.time directly without performing
185 Note that, as above, this will change the instant to
186 which the time refers.
188 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
192 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
196 return t.replace(tzinfo=tz)
199 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
201 Translates dt into a different timezone by adjusting the year, month,
202 day, hour, minute, second, micro, etc... appropriately. The returned
203 dt is the same instant in another timezone.
206 >>> d = now_pacific()
207 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
210 >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
211 >>> o.tzinfo.tzname(o)[0] # Again, could be EST or EDT
215 >>> expected = h + 3 # Three hours later in E?T than P?T
216 >>> expected = expected % 24 # Handle edge case
217 >>> expected == o.hour
220 return dt.replace().astimezone(tz=tz)
223 def now() -> datetime.datetime:
225 What time is it? Result is a timezone naive datetime.
227 return datetime.datetime.now()
230 def now_pacific() -> datetime.datetime:
232 What time is it? Result in US/Pacific time (PST/PDT)
234 return datetime.datetime.now(pytz.timezone("US/Pacific"))
237 def date_to_datetime(date: datetime.date) -> datetime.datetime:
239 Given a date, return a datetime with hour/min/sec zero (midnight)
242 >>> date_to_datetime(datetime.date(2021, 12, 25))
243 datetime.datetime(2021, 12, 25, 0, 0)
246 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
249 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
251 Given a time, returns that time as a datetime with a date component
252 set based on the current date. If the time passed is timezone aware,
253 the resulting datetime will also be (and will use the same tzinfo).
254 If the time is timezone naive, the datetime returned will be too.
256 >>> t = datetime.time(13, 14, 0)
257 >>> d = now_pacific().date()
258 >>> dt = time_to_datetime_today(t)
265 >>> dt.tzinfo == t.tzinfo
268 >>> dt.tzinfo == None
271 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
275 >>> dt = time_to_datetime_today(t)
276 >>> dt.tzinfo == None
281 return datetime.datetime.combine(now_pacific(), time, tz)
284 def date_and_time_to_datetime(
285 date: datetime.date, time: datetime.time
286 ) -> datetime.datetime:
288 Given a date and time, merge them and return a datetime.
291 >>> d = datetime.date(2021, 12, 25)
292 >>> t = datetime.time(12, 30, 0, 0)
293 >>> date_and_time_to_datetime(d, t)
294 datetime.datetime(2021, 12, 25, 12, 30)
297 return datetime.datetime(
308 def datetime_to_date_and_time(
309 dt: datetime.datetime,
310 ) -> Tuple[datetime.date, datetime.time]:
311 """Return the component date and time objects of a datetime in a
312 Tuple given a datetime.
315 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
316 >>> (d, t) = datetime_to_date_and_time(dt)
318 datetime.date(2021, 12, 25)
320 datetime.time(12, 30)
323 return (dt.date(), dt.timetz())
326 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
327 """Return just the date part of a datetime.
330 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
331 >>> datetime_to_date(dt)
332 datetime.date(2021, 12, 25)
335 return datetime_to_date_and_time(dt)[0]
338 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
339 """Return just the time part of a datetime.
342 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
343 >>> datetime_to_time(dt)
344 datetime.time(12, 30)
347 return datetime_to_date_and_time(dt)[1]
350 class TimeUnit(enum.IntEnum):
351 """An enum to represent units with which we can compute deltas."""
370 def is_valid(cls, value: Any):
371 if isinstance(value, int):
372 return cls(value) is not None
373 elif isinstance(value, TimeUnit):
374 return cls(value.value) is not None
375 elif isinstance(value, str):
376 return cls.__members__[value] is not None
382 def n_timeunits_from_base(
383 count: int, unit: TimeUnit, base: datetime.datetime
384 ) -> datetime.datetime:
385 """Return a datetime that is N units before/after a base datetime.
386 e.g. 3 Wednesdays from base datetime, 2 weeks from base date, 10
387 years before base datetime, 13 minutes after base datetime, etc...
388 Note: to indicate before/after the base date, use a positive or
391 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
393 The next (1) Monday from the base datetime:
395 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
396 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
398 Ten (10) years after the base datetime:
400 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
401 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
403 Fifty (50) working days (M..F, not counting holidays) after base datetime:
405 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
406 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
408 Fifty (50) days (including weekends and holidays) after base datetime:
410 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
411 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
413 Fifty (50) months before (note negative count) base datetime:
415 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
416 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
418 Fifty (50) hours after base datetime:
420 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
421 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
423 Fifty (50) minutes before base datetime:
425 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
426 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
428 Fifty (50) seconds from base datetime:
430 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
431 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
433 Next month corner case -- it will try to make Feb 31, 2022 then count
436 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
437 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
438 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
440 Last month with the same corner case
442 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
443 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
444 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
447 assert TimeUnit.is_valid(unit)
452 if unit == TimeUnit.DAYS:
453 timedelta = datetime.timedelta(days=count)
454 return base + timedelta
457 elif unit == TimeUnit.HOURS:
458 timedelta = datetime.timedelta(hours=count)
459 return base + timedelta
461 # N minutes from base
462 elif unit == TimeUnit.MINUTES:
463 timedelta = datetime.timedelta(minutes=count)
464 return base + timedelta
466 # N seconds from base
467 elif unit == TimeUnit.SECONDS:
468 timedelta = datetime.timedelta(seconds=count)
469 return base + timedelta
471 # N workdays from base
472 elif unit == TimeUnit.WORKDAYS:
475 timedelta = datetime.timedelta(days=-1)
477 timedelta = datetime.timedelta(days=1)
478 skips = holidays.US(years=base.year).keys()
482 if base.year != old_year:
483 skips = holidays.US(years=base.year).keys()
486 and datetime.date(base.year, base.month, base.day) not in skips
492 elif unit == TimeUnit.WEEKS:
493 timedelta = datetime.timedelta(weeks=count)
494 base = base + timedelta
498 elif unit == TimeUnit.MONTHS:
499 month_term = count % 12
500 year_term = count // 12
501 new_month = base.month + month_term
505 new_year = base.year + year_term
509 ret = datetime.datetime(
525 elif unit == TimeUnit.YEARS:
526 new_year = base.year + count
527 return datetime.datetime(
549 raise ValueError(unit)
551 # N weekdays from base (e.g. 4 wednesdays from today)
552 direction = 1 if count > 0 else -1
554 timedelta = datetime.timedelta(days=direction)
558 if dow == unit.value and start != base:
562 base = base + timedelta
565 def get_format_string(
567 date_time_separator=" ",
568 include_timezone=True,
569 include_dayname=False,
570 use_month_abbrevs=False,
571 include_seconds=True,
572 include_fractional=False,
576 Helper to return a format string without looking up the documentation
579 >>> get_format_string()
580 '%Y/%m/%d %I:%M:%S%p%z'
582 >>> get_format_string(date_time_separator='@')
583 '%Y/%m/%d@%I:%M:%S%p%z'
585 >>> get_format_string(include_dayname=True)
586 '%a/%Y/%m/%d %I:%M:%S%p%z'
588 >>> get_format_string(include_dayname=True, twelve_hour=False)
589 '%a/%Y/%m/%d %H:%M:%S%z'
596 if use_month_abbrevs:
597 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
599 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
609 if include_fractional:
616 def datetime_to_string(
617 dt: datetime.datetime,
619 date_time_separator=" ",
620 include_timezone=True,
621 include_dayname=False,
622 use_month_abbrevs=False,
623 include_seconds=True,
624 include_fractional=False,
628 A nice way to convert a datetime into a string; arguably better than
629 just printing it and relying on it __repr__().
631 >>> d = string_to_datetime(
632 ... "2021/09/10 11:24:51AM-0700",
635 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
636 >>> datetime_to_string(d)
637 '2021/09/10 11:24:51AM-0700'
638 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
639 'Fri/2021/09/10 11:24AM-0700'
642 fstring = get_format_string(
643 date_time_separator=date_time_separator,
644 include_timezone=include_timezone,
645 include_dayname=include_dayname,
646 use_month_abbrevs=use_month_abbrevs,
647 include_seconds=include_seconds,
648 include_fractional=include_fractional,
649 twelve_hour=twelve_hour,
651 return dt.strftime(fstring).strip()
654 def string_to_datetime(
657 date_time_separator=" ",
658 include_timezone=True,
659 include_dayname=False,
660 use_month_abbrevs=False,
661 include_seconds=True,
662 include_fractional=False,
664 ) -> Tuple[datetime.datetime, str]:
665 """A nice way to convert a string into a datetime. Returns both the
666 datetime and the format string used to parse it. Also consider
667 dateparse.dateparse_utils for a full parser alternative.
669 >>> d = string_to_datetime(
670 ... "2021/09/10 11:24:51AM-0700",
673 (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')
676 fstring = get_format_string(
677 date_time_separator=date_time_separator,
678 include_timezone=include_timezone,
679 include_dayname=include_dayname,
680 use_month_abbrevs=use_month_abbrevs,
681 include_seconds=include_seconds,
682 include_fractional=include_fractional,
683 twelve_hour=twelve_hour,
685 return (datetime.datetime.strptime(txt, fstring), fstring)
688 def timestamp() -> str:
689 """Return a timestamp for right now in Pacific timezone."""
690 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
691 return datetime_to_string(ts, include_timezone=True)
695 dt: datetime.datetime,
697 include_seconds=True,
698 include_fractional=False,
699 include_timezone=False,
702 """A nice way to convert a datetime into a time (only) string.
703 This ignores the date part of the datetime.
705 >>> d = string_to_datetime(
706 ... "2021/09/10 11:24:51AM-0700",
709 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
711 >>> time_to_string(d)
714 >>> time_to_string(d, include_seconds=False)
717 >>> time_to_string(d, include_seconds=False, include_timezone=True)
731 if include_fractional:
735 return dt.strftime(fstring).strip()
738 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
739 """Convert a delta in seconds into a timedelta."""
740 return datetime.timedelta(seconds=seconds)
743 MinuteOfDay = NewType("MinuteOfDay", int)
746 def minute_number(hour: int, minute: int) -> MinuteOfDay:
748 Convert hour:minute into minute number from start of day.
750 >>> minute_number(0, 0)
753 >>> minute_number(9, 15)
756 >>> minute_number(23, 59)
760 return MinuteOfDay(hour * 60 + minute)
763 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
765 Convert a datetime into a minute number (of the day). Note that
766 this ignores the date part of the datetime and only uses the time
769 >>> d = string_to_datetime(
770 ... "2021/09/10 11:24:51AM-0700",
773 >>> datetime_to_minute_number(d)
777 return minute_number(dt.hour, dt.minute)
780 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
782 Convert a datetime.time into a minute number.
784 >>> t = datetime.time(5, 15)
785 >>> time_to_minute_number(t)
789 return minute_number(t.hour, t.minute)
792 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
794 Convert minute number from start of day into hour:minute am/pm
797 >>> minute_number_to_time_string(315)
800 >>> minute_number_to_time_string(684)
804 hour = minute_num // 60
805 minute = minute_num % 60
814 return f"{hour:2}:{minute:02}{ampm}"
817 def parse_duration(duration: str, raise_on_error=False) -> int:
819 Parse a duration in string form into a delta seconds.
821 >>> parse_duration('15 days, 2 hours')
824 >>> parse_duration('15d 2h')
827 >>> parse_duration('100s')
830 >>> parse_duration('3min 2sec')
833 >>> parse_duration('recent')
836 >>> parse_duration('recent', raise_on_error=True)
837 Traceback (most recent call last):
839 ValueError: recent is not a valid duration.
842 if duration.isdigit():
846 r'(\d+ *d[ays]*)* *(\d+ *h[ours]*)* *(\d+ *m[inutes]*)* *(\d+ *[seconds]*)',
849 if not m and raise_on_error:
850 raise ValueError(f'{duration} is not a valid duration.')
853 m = re.search(r'(\d+) *d[ays]*', duration)
855 seconds += int(m.group(1)) * 60 * 60 * 24
856 m = re.search(r'(\d+) *h[ours]*', duration)
858 seconds += int(m.group(1)) * 60 * 60
859 m = re.search(r'(\d+) *m[inutes]*', duration)
861 seconds += int(m.group(1)) * 60
862 m = re.search(r'(\d+) *s[econds]*', duration)
864 seconds += int(m.group(1))
868 def describe_duration(seconds: int, *, include_seconds=False) -> str:
870 Describe a duration represented as a count of seconds nicely.
872 >>> describe_duration(182)
875 >>> describe_duration(182, include_seconds=True)
876 '3 minutes, and 2 seconds'
878 >>> describe_duration(100, include_seconds=True)
879 '1 minute, and 40 seconds'
881 describe_duration(1303200)
885 days = divmod(seconds, constants.SECONDS_PER_DAY)
886 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
887 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
891 descr = f"{int(days[0])} days, "
896 descr = descr + f"{int(hours[0])} hours, "
898 descr = descr + "1 hour, "
900 if not include_seconds and len(descr) > 0:
901 descr = descr + "and "
904 descr = descr + "1 minute"
906 descr = descr + f"{int(minutes[0])} minutes"
911 descr = descr + 'and '
914 descr = descr + '1 second'
916 descr = descr + f'{s} seconds'
920 def describe_timedelta(delta: datetime.timedelta) -> str:
922 Describe a duration represented by a timedelta object.
924 >>> d = datetime.timedelta(1, 600)
925 >>> describe_timedelta(d)
926 '1 day, and 10 minutes'
929 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
932 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
934 Describe a duration briefly.
936 >>> describe_duration_briefly(182)
939 >>> describe_duration_briefly(182, include_seconds=True)
942 >>> describe_duration_briefly(100, include_seconds=True)
945 describe_duration_briefly(1303200)
949 days = divmod(seconds, constants.SECONDS_PER_DAY)
950 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
951 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
955 descr = f'{int(days[0])}d '
957 descr = descr + f'{int(hours[0])}h '
958 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
959 descr = descr + f'{int(minutes[0])}m '
960 if minutes[1] > 0 and include_seconds:
961 descr = descr + f'{int(minutes[1])}s'
965 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
967 Describe a duration represented by a timedelta object.
969 >>> d = datetime.timedelta(1, 600)
970 >>> describe_timedelta_briefly(d)
974 return describe_duration_briefly(
975 int(delta.total_seconds())
976 ) # Note: drops milliseconds
979 if __name__ == '__main__':