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:
394 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
395 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
397 Ten (10) years after the base datetime:
398 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
399 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
401 Fifty (50) working days (M..F, not counting holidays) after base datetime:
402 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
403 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
405 Fifty (50) days (including weekends and holidays) after base datetime:
406 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
407 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
409 Fifty (50) months before (note negative count) base datetime:
410 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
411 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
413 Fifty (50) hours after base datetime:
414 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
415 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
417 Fifty (50) minutes before base datetime:
418 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
419 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
421 Fifty (50) seconds from base datetime:
422 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
423 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
425 Next month corner case -- it will try to make Feb 31, 2022 then count
427 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
428 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
429 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
431 Last month with the same corner case
432 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
433 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
434 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
437 assert TimeUnit.is_valid(unit)
442 if unit == TimeUnit.DAYS:
443 timedelta = datetime.timedelta(days=count)
444 return base + timedelta
447 elif unit == TimeUnit.HOURS:
448 timedelta = datetime.timedelta(hours=count)
449 return base + timedelta
451 # N minutes from base
452 elif unit == TimeUnit.MINUTES:
453 timedelta = datetime.timedelta(minutes=count)
454 return base + timedelta
456 # N seconds from base
457 elif unit == TimeUnit.SECONDS:
458 timedelta = datetime.timedelta(seconds=count)
459 return base + timedelta
461 # N workdays from base
462 elif unit == TimeUnit.WORKDAYS:
465 timedelta = datetime.timedelta(days=-1)
467 timedelta = datetime.timedelta(days=1)
468 skips = holidays.US(years=base.year).keys()
472 if base.year != old_year:
473 skips = holidays.US(years=base.year).keys()
476 and datetime.date(base.year, base.month, base.day) not in skips
482 elif unit == TimeUnit.WEEKS:
483 timedelta = datetime.timedelta(weeks=count)
484 base = base + timedelta
488 elif unit == TimeUnit.MONTHS:
489 month_term = count % 12
490 year_term = count // 12
491 new_month = base.month + month_term
495 new_year = base.year + year_term
499 ret = datetime.datetime(
515 elif unit == TimeUnit.YEARS:
516 new_year = base.year + count
517 return datetime.datetime(
539 raise ValueError(unit)
541 # N weekdays from base (e.g. 4 wednesdays from today)
542 direction = 1 if count > 0 else -1
544 timedelta = datetime.timedelta(days=direction)
548 if dow == unit.value and start != base:
552 base = base + timedelta
555 def get_format_string(
557 date_time_separator=" ",
558 include_timezone=True,
559 include_dayname=False,
560 use_month_abbrevs=False,
561 include_seconds=True,
562 include_fractional=False,
566 Helper to return a format string without looking up the documentation
569 >>> get_format_string()
570 '%Y/%m/%d %I:%M:%S%p%z'
572 >>> get_format_string(date_time_separator='@')
573 '%Y/%m/%d@%I:%M:%S%p%z'
575 >>> get_format_string(include_dayname=True)
576 '%a/%Y/%m/%d %I:%M:%S%p%z'
578 >>> get_format_string(include_dayname=True, twelve_hour=False)
579 '%a/%Y/%m/%d %H:%M:%S%z'
586 if use_month_abbrevs:
587 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
589 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
599 if include_fractional:
606 def datetime_to_string(
607 dt: datetime.datetime,
609 date_time_separator=" ",
610 include_timezone=True,
611 include_dayname=False,
612 use_month_abbrevs=False,
613 include_seconds=True,
614 include_fractional=False,
618 A nice way to convert a datetime into a string; arguably better than
619 just printing it and relying on it __repr__().
621 >>> d = string_to_datetime(
622 ... "2021/09/10 11:24:51AM-0700",
625 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
626 >>> datetime_to_string(d)
627 '2021/09/10 11:24:51AM-0700'
628 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
629 'Fri/2021/09/10 11:24AM-0700'
632 fstring = get_format_string(
633 date_time_separator=date_time_separator,
634 include_timezone=include_timezone,
635 include_dayname=include_dayname,
636 use_month_abbrevs=use_month_abbrevs,
637 include_seconds=include_seconds,
638 include_fractional=include_fractional,
639 twelve_hour=twelve_hour,
641 return dt.strftime(fstring).strip()
644 def string_to_datetime(
647 date_time_separator=" ",
648 include_timezone=True,
649 include_dayname=False,
650 use_month_abbrevs=False,
651 include_seconds=True,
652 include_fractional=False,
654 ) -> Tuple[datetime.datetime, str]:
655 """A nice way to convert a string into a datetime. Returns both the
656 datetime and the format string used to parse it. Also consider
657 dateparse.dateparse_utils for a full parser alternative.
659 >>> d = string_to_datetime(
660 ... "2021/09/10 11:24:51AM-0700",
663 (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')
666 fstring = get_format_string(
667 date_time_separator=date_time_separator,
668 include_timezone=include_timezone,
669 include_dayname=include_dayname,
670 use_month_abbrevs=use_month_abbrevs,
671 include_seconds=include_seconds,
672 include_fractional=include_fractional,
673 twelve_hour=twelve_hour,
675 return (datetime.datetime.strptime(txt, fstring), fstring)
678 def timestamp() -> str:
679 """Return a timestamp for right now in Pacific timezone."""
680 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
681 return datetime_to_string(ts, include_timezone=True)
685 dt: datetime.datetime,
687 include_seconds=True,
688 include_fractional=False,
689 include_timezone=False,
692 """A nice way to convert a datetime into a time (only) string.
693 This ignores the date part of the datetime.
695 >>> d = string_to_datetime(
696 ... "2021/09/10 11:24:51AM-0700",
699 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
701 >>> time_to_string(d)
704 >>> time_to_string(d, include_seconds=False)
707 >>> time_to_string(d, include_seconds=False, include_timezone=True)
721 if include_fractional:
725 return dt.strftime(fstring).strip()
728 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
729 """Convert a delta in seconds into a timedelta."""
730 return datetime.timedelta(seconds=seconds)
733 MinuteOfDay = NewType("MinuteOfDay", int)
736 def minute_number(hour: int, minute: int) -> MinuteOfDay:
738 Convert hour:minute into minute number from start of day.
740 >>> minute_number(0, 0)
743 >>> minute_number(9, 15)
746 >>> minute_number(23, 59)
750 return MinuteOfDay(hour * 60 + minute)
753 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
755 Convert a datetime into a minute number (of the day). Note that
756 this ignores the date part of the datetime and only uses the time
759 >>> d = string_to_datetime(
760 ... "2021/09/10 11:24:51AM-0700",
763 >>> datetime_to_minute_number(d)
767 return minute_number(dt.hour, dt.minute)
770 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
772 Convert a datetime.time into a minute number.
774 >>> t = datetime.time(5, 15)
775 >>> time_to_minute_number(t)
779 return minute_number(t.hour, t.minute)
782 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
784 Convert minute number from start of day into hour:minute am/pm
787 >>> minute_number_to_time_string(315)
790 >>> minute_number_to_time_string(684)
794 hour = minute_num // 60
795 minute = minute_num % 60
804 return f"{hour:2}:{minute:02}{ampm}"
807 def parse_duration(duration: str, raise_on_error=False) -> int:
809 Parse a duration in string form into a delta seconds.
811 >>> parse_duration('15 days, 2 hours')
814 >>> parse_duration('15d 2h')
817 >>> parse_duration('100s')
820 >>> parse_duration('3min 2sec')
823 >>> parse_duration('recent')
826 >>> parse_duration('recent', raise_on_error=True)
827 Traceback (most recent call last):
829 ValueError: recent is not a valid duration.
832 if duration.isdigit():
836 r'(\d+ *d[ays]*)* *(\d+ *h[ours]*)* *(\d+ *m[inutes]*)* *(\d+ *[seconds]*)',
839 if not m and raise_on_error:
840 raise ValueError(f'{duration} is not a valid duration.')
843 m = re.search(r'(\d+) *d[ays]*', duration)
845 seconds += int(m.group(1)) * 60 * 60 * 24
846 m = re.search(r'(\d+) *h[ours]*', duration)
848 seconds += int(m.group(1)) * 60 * 60
849 m = re.search(r'(\d+) *m[inutes]*', duration)
851 seconds += int(m.group(1)) * 60
852 m = re.search(r'(\d+) *s[econds]*', duration)
854 seconds += int(m.group(1))
858 def describe_duration(seconds: int, *, include_seconds=False) -> str:
860 Describe a duration represented as a count of seconds nicely.
862 >>> describe_duration(182)
865 >>> describe_duration(182, include_seconds=True)
866 '3 minutes, and 2 seconds'
868 >>> describe_duration(100, include_seconds=True)
869 '1 minute, and 40 seconds'
871 describe_duration(1303200)
875 days = divmod(seconds, constants.SECONDS_PER_DAY)
876 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
877 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
881 descr = f"{int(days[0])} days, "
886 descr = descr + f"{int(hours[0])} hours, "
888 descr = descr + "1 hour, "
890 if not include_seconds and len(descr) > 0:
891 descr = descr + "and "
894 descr = descr + "1 minute"
896 descr = descr + f"{int(minutes[0])} minutes"
901 descr = descr + 'and '
904 descr = descr + '1 second'
906 descr = descr + f'{s} seconds'
910 def describe_timedelta(delta: datetime.timedelta) -> str:
912 Describe a duration represented by a timedelta object.
914 >>> d = datetime.timedelta(1, 600)
915 >>> describe_timedelta(d)
916 '1 day, and 10 minutes'
919 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
922 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
924 Describe a duration briefly.
926 >>> describe_duration_briefly(182)
929 >>> describe_duration_briefly(182, include_seconds=True)
932 >>> describe_duration_briefly(100, include_seconds=True)
935 describe_duration_briefly(1303200)
939 days = divmod(seconds, constants.SECONDS_PER_DAY)
940 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
941 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
945 descr = f'{int(days[0])}d '
947 descr = descr + f'{int(hours[0])}h '
948 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
949 descr = descr + f'{int(minutes[0])}m '
950 if minutes[1] > 0 and include_seconds:
951 descr = descr + f'{int(minutes[1])}s'
955 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
957 Describe a duration represented by a timedelta object.
959 >>> d = datetime.timedelta(1, 600)
960 >>> describe_timedelta_briefly(d)
964 return describe_duration_briefly(
965 int(delta.total_seconds())
966 ) # Note: drops milliseconds
969 if __name__ == '__main__':