3 # © Copyright 2021-2023, 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.datetimes import constants
18 logger = logging.getLogger(__name__)
21 def is_timezone_aware(dt: datetime.datetime) -> bool:
23 Checks whether a datetime is timezone aware or naive.
24 See: https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
27 dt: the datetime to check for timezone awareness
30 True if the datetime argument is timezone aware or
33 >>> is_timezone_aware(datetime.datetime.now())
36 >>> is_timezone_aware(now_pacific())
40 return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
43 def is_timezone_naive(dt: datetime.datetime) -> bool:
44 """Inverse of :meth:`is_timezone_aware`.
45 See: https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
48 dt: the datetime to check
51 True if the dt argument is timezone naive, False otherwise
53 >>> is_timezone_naive(datetime.datetime.now())
56 >>> is_timezone_naive(now_pacific())
60 return not is_timezone_aware(dt)
63 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
65 Remove the timezone from a datetime. Silently ignores datetimes
66 which are already timezone naive.
69 dt: the datetime to remove timezone from
72 A datetime identical to dt, the input argument, except for
73 that the timezone has been removed.
75 See also :meth:`add_timezone`, :meth:`replace_timezone`, :meth:`translate_timezone`.
79 This does not change the hours, minutes, seconds,
80 months, days, years, etc... Thus, the instant to which this
81 timestamp refers will change when the timezone is added.
84 >>> now = now_pacific()
85 >>> now.tzinfo == None
88 >>> "US/Pacific" in now.tzinfo.__repr__()
91 >>> dt = strip_timezone(now)
98 >>> dt.hour == now.hour
101 if is_timezone_naive(dt):
103 return replace_timezone(dt, None)
106 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
108 Adds a timezone to a timezone naive datetime.
111 dt: the datetime to insert a timezone onto
112 tz: the timezone to insert
114 See also :meth:`replace_timezone`, :meth:`strip_timezone`, :meth:`translate_timezone`.
117 A datetime identical to dt, the input datetime, except for
118 that a timezone has been added.
121 ValueError: if dt is already a timezone aware datetime.
125 This doesn't change the hour, minute, second, day, month, etc...
126 of the input timezone. It simply adds a timezone to it. Adding
127 a timezone this way will likely change the instant to which the
128 datetime refers. See examples.
130 >>> now = datetime.datetime.now()
131 >>> is_timezone_aware(now)
134 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
135 >>> is_timezone_aware(now_pacific)
138 >>> "US/Pacific" in now_pacific.tzinfo.__repr__()
141 >>> now.hour == now_pacific.hour
143 >>> now.minute == now_pacific.minute
148 # This doesn't work, tz requires a timezone naive dt. Two options
150 # 1. Use strip_timezone and try again.
151 # 2. Replace the timezone on your dt object via replace_timezone.
152 # Be aware that this changes the instant to which the dt refers
153 # and, further, can introduce weirdness like UTC offsets that
154 # are weird (e.g. not an even multiple of an hour, etc...)
155 if is_timezone_aware(dt):
159 f"{dt} is already timezone aware; use replace_timezone or translate_timezone "
160 + "depending on the semantics you want. See the pydocs / code."
162 return dt.replace(tzinfo=tz)
165 def replace_timezone(
166 dt: datetime.datetime, tz: Optional[datetime.tzinfo]
167 ) -> datetime.datetime:
169 Replaces the timezone on a timezone aware datetime object directly
170 (leaving the year, month, day, hour, minute, second, micro,
171 etc... alone). The same as calling :meth:`strip_timezone` followed
172 by :meth:`add_timezone`.
174 Works with timezone aware and timezone naive dts but for the
175 latter it is probably better to use :meth:`add_timezone` or just
176 create it with a `tz` parameter. Using this can have weird side
177 effects like UTC offsets that are not an even multiple of an hour,
181 dt: the datetime whose timezone should be changed
185 The resulting datetime. Hour, minute, second, etc... are unmodified.
188 See also :meth:`add_timezone`, :meth:`strip_timezone`, :meth:`translate_timezone`.
192 This code isn't changing the hour, minute, second, day, month, etc...
193 of the datetime. It's just messing with the timezone. Changing
194 the timezone without changing the time causes the instant to which
195 the datetime refers to change. For example, if passed 7:01pm PST
196 and asked to make it EST, the result will be 7:01pm EST. See
199 >>> from pytz import UTC
200 >>> d = now_pacific()
201 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
204 >>> o = replace_timezone(d, UTC)
205 >>> o.tzinfo.tzname(o)
210 if is_timezone_aware(dt):
212 "%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.",
215 return datetime.datetime(
227 return add_timezone(dt, tz)
232 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
233 """Replaces the timezone on a datetime.time directly without performing
237 t: the time to change the timezone on
238 tz: the new timezone desired
241 A time with hour, minute, second, etc... identical to the input
242 time but with timezone replaced.
246 This code isn't changing the hour, minute, second, etc...
247 of the time. It's just messing with the timezone. Changing
248 the timezone without changing the time causes the instant to which
249 the datetime refers to change. For example, if passed 7:01pm PST
250 and asked to make it EST, the result will be 7:01pm EST. See
253 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
257 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
261 return t.replace(tzinfo=tz)
264 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
266 Translates dt into a different timezone by adjusting the year, month,
267 day, hour, minute, second, micro, etc... appropriately. The returned
268 dt is the same instant in another timezone.
271 dt: the datetime whose timezone should be translated.
272 tz: the desired timezone
275 A new datetime object that represents the same instant as the
276 input datetime but in the desired timezone. Modifies hour, minute,
277 seconds, day, etc... as necessary for the instant to be preserved.
278 For example, if you pass 11:01pm PST in and ask for it to be
279 translated to EST you would get 2:01am the next day EST back
282 See also :meth:`replace_timezone`, :meth:`strip_timezone`.
285 >>> d = now_pacific()
286 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
289 >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
290 >>> o.tzinfo.tzname(o)[0] # Again, could be EST or EDT
294 >>> expected = h + 3 # Three hours later in E?T than P?T
295 >>> expected = expected % 24 # Handle edge case
296 >>> expected == o.hour
299 return dt.replace().astimezone(tz=tz)
302 def now() -> datetime.datetime:
304 What time is it? Result is a timezone naive datetime.
306 return datetime.datetime.now()
309 def now_pacific() -> datetime.datetime:
311 What time is it? Result in US/Pacific time (PST/PDT)
313 return datetime.datetime.now(pytz.timezone("US/Pacific"))
316 def date_to_datetime(date: datetime.date) -> datetime.datetime:
318 Given a date, return a datetime with hour/min/sec zero (midnight)
321 date: the date desired
324 A datetime with the same month, day, and year as the input
325 date and hours, minutes, seconds set to 12:00:00am.
328 >>> date_to_datetime(datetime.date(2021, 12, 25))
329 datetime.datetime(2021, 12, 25, 0, 0)
331 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
334 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
336 Given a time, returns that time as a datetime with a date component
337 set based on the current date. If the time passed is timezone aware,
338 the resulting datetime will also be (and will use the same tzinfo).
339 If the time is timezone naive, the datetime returned will be too.
342 time: the time desired
345 datetime with hour, minute, second, timezone set to time and
346 day, month, year set to "today".
348 >>> t = datetime.time(13, 14, 0)
349 >>> d = now_pacific().date()
350 >>> dt = time_to_datetime_today(t)
357 >>> dt.tzinfo == t.tzinfo
360 >>> dt.tzinfo == None
363 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
367 >>> dt = time_to_datetime_today(t)
368 >>> dt.tzinfo == None
373 return datetime.datetime.combine(now_pacific(), time, tz)
376 def date_and_time_to_datetime(
377 date: datetime.date, time: datetime.time
378 ) -> datetime.datetime:
380 Given a date and time, merge them and return a datetime.
383 date: the date component
384 time: the time component
387 A datetime with the time component set from time and the date
388 component set from date.
391 >>> d = datetime.date(2021, 12, 25)
392 >>> t = datetime.time(12, 30, 0, 0)
393 >>> date_and_time_to_datetime(d, t)
394 datetime.datetime(2021, 12, 25, 12, 30)
396 return datetime.datetime(
407 def datetime_to_date_and_time(
408 dt: datetime.datetime,
409 ) -> Tuple[datetime.date, datetime.time]:
410 """Return the component date and time objects of a datetime in a
411 Tuple given a datetime.
414 dt: the datetime to decompose
417 A tuple whose first element contains a datetime.date that holds
418 the day, month, year, etc... from the input dt and whose second
419 element contains a datetime.time with hour, minute, second, micros,
420 and timezone set from the input dt.
423 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
424 >>> (d, t) = datetime_to_date_and_time(dt)
426 datetime.date(2021, 12, 25)
428 datetime.time(12, 30)
430 return (dt.date(), dt.timetz())
433 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
434 """Return just the date part of a datetime.
440 A datetime.date with month, day and year set from input dt.
443 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
444 >>> datetime_to_date(dt)
445 datetime.date(2021, 12, 25)
447 return datetime_to_date_and_time(dt)[0]
450 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
451 """Return just the time part of a datetime.
457 A datetime.time with hour, minute, second, micros, and
458 timezone set from the input dt.
461 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
462 >>> datetime_to_time(dt)
463 datetime.time(12, 30)
465 return datetime_to_date_and_time(dt)[1]
468 class TimeUnit(enum.IntEnum):
469 """An enum to represent units with which we can compute deltas."""
488 def is_valid(cls, value: Any):
491 value: a value to be checked
494 True is input value is a valid TimeUnit, False otherwise.
496 if isinstance(value, int):
497 return cls(value) is not None
498 elif isinstance(value, TimeUnit):
499 return cls(value.value) is not None
500 elif isinstance(value, str):
501 return cls.__members__[value] is not None
507 def n_timeunits_from_base(
508 count: int, unit: TimeUnit, base: datetime.datetime
509 ) -> datetime.datetime:
510 """Return a datetime that is N units before/after a base datetime.
513 - 3 Wednesdays from base datetime,
514 - 2 weeks from base date,
515 - 10 years before base datetime,
516 - 13 minutes after base datetime, etc...
519 count: signed number that indicates N units before/after the base.
520 unit: the timeunit that we are counting by.
521 base: a datetime representing the base date the result should be
525 ValueError: unit is invalid
528 A datetime that is count units before of after the base datetime.
532 To indicate before/after the base date, use a positive or
535 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
537 The next (1) Monday from the base datetime:
539 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
540 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
542 Ten (10) years after the base datetime:
544 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
545 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
547 Fifty (50) working days (M..F, not counting holidays) after base datetime:
549 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
550 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
552 Fifty (50) days (including weekends and holidays) after base datetime:
554 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
555 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
557 Fifty (50) months before (note negative count) base datetime:
559 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
560 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
562 Fifty (50) hours after base datetime:
564 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
565 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
567 Fifty (50) minutes before base datetime:
569 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
570 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
572 Fifty (50) seconds from base datetime:
574 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
575 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
577 Next month corner case -- it will try to make Feb 31, 2022 then count
580 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
581 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
582 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
584 Last month with the same corner case
586 >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
587 >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
588 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
590 assert TimeUnit.is_valid(unit)
595 if unit == TimeUnit.DAYS:
596 timedelta = datetime.timedelta(days=count)
597 return base + timedelta
600 elif unit == TimeUnit.HOURS:
601 timedelta = datetime.timedelta(hours=count)
602 return base + timedelta
604 # N minutes from base
605 elif unit == TimeUnit.MINUTES:
606 timedelta = datetime.timedelta(minutes=count)
607 return base + timedelta
609 # N seconds from base
610 elif unit == TimeUnit.SECONDS:
611 timedelta = datetime.timedelta(seconds=count)
612 return base + timedelta
614 # N workdays from base
615 elif unit == TimeUnit.WORKDAYS:
618 timedelta = datetime.timedelta(days=-1)
620 timedelta = datetime.timedelta(days=1)
621 skips = holidays.US(years=base.year).keys()
625 if base.year != old_year:
626 skips = holidays.US(years=base.year).keys()
629 and datetime.date(base.year, base.month, base.day) not in skips
635 elif unit == TimeUnit.WEEKS:
636 timedelta = datetime.timedelta(weeks=count)
637 base = base + timedelta
641 elif unit == TimeUnit.MONTHS:
642 month_term = count % 12
643 year_term = count // 12
644 new_month = base.month + month_term
648 new_year = base.year + year_term
652 ret = datetime.datetime(
668 elif unit == TimeUnit.YEARS:
669 new_year = base.year + count
670 return datetime.datetime(
692 raise ValueError(unit)
694 # N weekdays from base (e.g. 4 wednesdays from today)
695 direction = 1 if count > 0 else -1
697 timedelta = datetime.timedelta(days=direction)
701 if dow == unit.value and start != base:
705 base = base + timedelta
708 def get_format_string(
710 date_time_separator: str = " ",
711 include_timezone: bool = True,
712 include_dayname: bool = False,
713 use_month_abbrevs: bool = False,
714 include_seconds: bool = True,
715 include_fractional: bool = False,
716 twelve_hour: bool = True,
719 Helper to return a format string without looking up the documentation
723 date_time_separator: character or string to use between the date
725 include_timezone: whether or not the result should include a timezone
726 include_dayname: whether or not the result should incude the dayname
727 (e.g. Monday, Wednesday, etc...)
728 use_month_abbrevs: whether or not to abbreviate (e.g. Jan) or spell out
729 (e.g. January) month names.
730 include_seconds: whether or not to include seconds in time.
731 include_fractional: whether or not to include micros in time output.
732 twelve_hour: use twelve hour (with am/pm) or twenty four hour time format?
735 The format string for use with strftime that follows the given
738 >>> get_format_string()
739 '%Y/%m/%d %I:%M:%S%p%z'
741 >>> get_format_string(date_time_separator='@')
742 '%Y/%m/%d@%I:%M:%S%p%z'
744 >>> get_format_string(include_dayname=True)
745 '%a/%Y/%m/%d %I:%M:%S%p%z'
747 >>> get_format_string(include_dayname=True, twelve_hour=False)
748 '%a/%Y/%m/%d %H:%M:%S%z'
755 if use_month_abbrevs:
756 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
758 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
768 if include_fractional:
775 def datetime_to_string(
776 dt: datetime.datetime,
778 date_time_separator: str = " ",
779 include_timezone: bool = True,
780 include_dayname: bool = False,
781 use_month_abbrevs: bool = False,
782 include_seconds: bool = True,
783 include_fractional: bool = False,
784 twelve_hour: bool = True,
787 A nice way to convert a datetime into a string; arguably better than
788 just printing it and relying on it __repr__().
791 dt: the datetime to represent
792 date_time_separator: the character or string to separate the date and time
793 pieces of the representation.
794 include_timezone: should we include a timezone in the representation?
795 include_dayname: should we include the dayname (e.g. Mon) in
796 the representation or omit it?
797 use_month_abbrevs: should we name the month briefly (e.g. Jan) or spell
798 it out fully (e.g. January) in the representation?
799 include_seconds: should we include seconds in the time?
800 include_fractional: should we include micros in the time?
801 twelve_hour: should we use twelve or twenty-four hour time format?
803 >>> d = string_to_datetime(
804 ... "2021/09/10 11:24:51AM-0700",
807 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
808 >>> datetime_to_string(d)
809 '2021/09/10 11:24:51AM-0700'
810 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
811 'Fri/2021/09/10 11:24AM-0700'
813 fstring = get_format_string(
814 date_time_separator=date_time_separator,
815 include_timezone=include_timezone,
816 include_dayname=include_dayname,
817 use_month_abbrevs=use_month_abbrevs,
818 include_seconds=include_seconds,
819 include_fractional=include_fractional,
820 twelve_hour=twelve_hour,
822 return dt.strftime(fstring).strip()
825 def string_to_datetime(
828 date_time_separator: str = " ",
829 include_timezone: bool = True,
830 include_dayname: bool = False,
831 use_month_abbrevs: bool = False,
832 include_seconds: bool = True,
833 include_fractional: bool = False,
834 twelve_hour: bool = True,
835 ) -> Tuple[datetime.datetime, str]:
836 """A nice way to convert a string into a datetime. Returns both the
837 datetime and the format string used to parse it. Also consider
838 :mod:`pyutils.datetimes.dateparse_utils` for a full parser alternative.
841 txt: the string to be converted into a datetime
842 date_time_separator: the character or string between the time and date
844 include_timezone: does the string include a timezone?
845 include_dayname: does the string include a dayname?
846 use_month_abbrevs: is the month abbreviated in the string (e.g. Feb)
847 or spelled out completely (e.g. February)?
848 include_seconds: does the string's time include seconds?
849 include_fractional: does the string's time include micros?
850 twelve_hour: is the string's time in twelve or twenty-four hour format?
853 A tuple containing the datetime parsed from string and the formatting
854 string used to parse it.
856 >>> d = string_to_datetime(
857 ... "2021/09/10 11:24:51AM-0700",
860 (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')
863 fstring = get_format_string(
864 date_time_separator=date_time_separator,
865 include_timezone=include_timezone,
866 include_dayname=include_dayname,
867 use_month_abbrevs=use_month_abbrevs,
868 include_seconds=include_seconds,
869 include_fractional=include_fractional,
870 twelve_hour=twelve_hour,
872 return (datetime.datetime.strptime(txt, fstring), fstring)
875 def timestamp() -> str:
878 A timestamp for right now in Pacific timezone.
880 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
881 return datetime_to_string(ts, include_timezone=True)
885 dt: datetime.datetime,
887 include_seconds: bool = True,
888 include_fractional: bool = False,
889 include_timezone: bool = False,
890 twelve_hour: bool = True,
892 """A nice way to convert a datetime into a time (only) string.
893 This ignores the date part of the datetime completely.
896 dt: the datetime whose time to represent
897 include_seconds: should seconds be included in the output?
898 include_fractional: should micros be included in the output?
899 include_timezone: should timezone be included in the output?
900 twelve_hour: use twelve or twenty-four hour format?
903 A string representing the time of the input datetime.
905 >>> d = string_to_datetime(
906 ... "2021/09/10 11:24:51AM-0700",
909 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
911 >>> time_to_string(d)
914 >>> time_to_string(d, include_seconds=False)
917 >>> time_to_string(d, include_seconds=False, include_timezone=True)
931 if include_fractional:
935 return dt.strftime(fstring).strip()
938 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
941 seconds: a count of seconds
944 A datetime.timedelta representing that count of seconds.
946 return datetime.timedelta(seconds=seconds)
949 MinuteOfDay = NewType("MinuteOfDay", int)
952 def minute_number(hour: int, minute: int) -> MinuteOfDay:
954 Convert hour:minute into minute number from start of day. That is,
955 if you imagine a day as a sequence of minutes from minute #0 up
956 to minute #1439, what minute number is, e.g., 6:52am?
959 hour: the hour to convert (0 <= hour <= 23)
960 minute: the minute to convert (0 <= minute <= 59)
963 The minute number requested. Raises `ValueError` on bad input.
966 ValueError: invalid hour or minute input argument
968 >>> minute_number(0, 0)
971 >>> minute_number(9, 15)
974 >>> minute_number(23, 59)
977 if hour < 0 or hour > 23:
978 raise ValueError(f"Bad hour: {hour}. Expected 0 <= hour <= 23")
979 if minute < 0 or minute > 59:
980 raise ValueError(f"Bad minute: {minute}. Expected 0 <= minute <= 59")
981 return MinuteOfDay(hour * 60 + minute)
984 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
986 Convert a datetime's time component into a minute number (of
987 the day). Note that this ignores the date part of the datetime
988 and only uses the time part.
991 dt: the datetime whose time is to be converted
994 The minute number (of the day) that represents the input datetime's
997 >>> d = string_to_datetime(
998 ... "2021/09/10 11:24:51AM-0700",
1001 >>> datetime_to_minute_number(d)
1004 return minute_number(dt.hour, dt.minute)
1007 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
1009 Convert a datetime.time into a minute number.
1012 t: a datetime.time to convert into a minute number.
1015 The minute number (of the day) of the input time.
1017 >>> t = datetime.time(5, 15)
1018 >>> time_to_minute_number(t)
1021 return minute_number(t.hour, t.minute)
1024 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
1026 Convert minute number from start of day into hour:minute am/pm
1030 minute_num: the minute number to convert into a string
1033 A string of the format "HH:MM[a|p]" that represents the time
1034 that the input minute_num refers to.
1036 >>> minute_number_to_time_string(315)
1039 >>> minute_number_to_time_string(684)
1042 hour = minute_num // 60
1043 minute = minute_num % 60
1052 return f"{hour:2}:{minute:02}{ampm}"
1055 def parse_duration(duration: str, raise_on_error: bool = False) -> int:
1057 Parse a duration in string form into a delta seconds.
1060 duration: a string form duration, see examples.
1061 raise_on_error: should we raise on invalid input or just
1062 return a zero duration?
1065 A count of seconds represented by the input string.
1068 ValueError: bad duration and raise_on_error is set.
1070 >>> parse_duration('15 days, 2 hours')
1073 >>> parse_duration('15d 2h')
1076 >>> parse_duration('100s')
1079 >>> parse_duration('3min 2sec')
1082 >>> parse_duration('recent')
1085 >>> parse_duration('recent', raise_on_error=True)
1086 Traceback (most recent call last):
1088 ValueError: recent is not a valid duration.
1090 if duration.isdigit():
1091 return int(duration)
1094 r"(\d+ *d[ays]*)* *(\d+ *h[ours]*)* *(\d+ *m[inutes]*)* *(\d+ *[seconds]*)",
1097 if not m and raise_on_error:
1098 raise ValueError(f"{duration} is not a valid duration.")
1101 m = re.search(r"(\d+) *d[ays]*", duration)
1103 seconds += int(m.group(1)) * 60 * 60 * 24
1104 m = re.search(r"(\d+) *h[ours]*", duration)
1106 seconds += int(m.group(1)) * 60 * 60
1107 m = re.search(r"(\d+) *m[inutes]*", duration)
1109 seconds += int(m.group(1)) * 60
1110 m = re.search(r"(\d+) *s[econds]*", duration)
1112 seconds += int(m.group(1))
1116 def describe_duration(seconds: int, *, include_seconds: bool = False) -> str:
1118 Describe a duration represented as a count of seconds nicely.
1121 seconds: the number of seconds in the duration to be represented.
1122 include_seconds: should we include or drop the seconds part in
1127 Of course if we drop the seconds part the result is not precise.
1130 >>> describe_duration(182)
1133 >>> describe_duration(182, include_seconds=True)
1134 '3 minutes, and 2 seconds'
1136 >>> describe_duration(100, include_seconds=True)
1137 '1 minute, and 40 seconds'
1139 describe_duration(1303200)
1142 days = divmod(seconds, constants.SECONDS_PER_DAY)
1143 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1144 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1148 descr = f"{int(days[0])} days, "
1153 descr = descr + f"{int(hours[0])} hours, "
1155 descr = descr + "1 hour, "
1157 if not include_seconds and len(descr) > 0:
1158 descr = descr + "and "
1161 descr = descr + "1 minute"
1163 descr = descr + f"{int(minutes[0])} minutes"
1166 descr = descr + ", "
1168 descr = descr + "and "
1171 descr = descr + "1 second"
1173 descr = descr + f"{s} seconds"
1177 def describe_timedelta(delta: datetime.timedelta) -> str:
1179 Describe a duration represented by a timedelta object.
1182 delta: the timedelta object that represents the duration to describe.
1185 A string representation of the input duration.
1189 Milliseconds are never included in the string representation of
1190 durations even through they may be represented by an input
1191 `datetime.timedelta`. Not for use when this level of precision
1194 >>> d = datetime.timedelta(1, 600)
1195 >>> describe_timedelta(d)
1196 '1 day, and 10 minutes'
1198 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
1201 def describe_duration_briefly(seconds: int, *, include_seconds: bool = False) -> str:
1203 Describe a duration briefly.
1206 seconds: the number of seconds in the duration to describe.
1207 include_seconds: should we include seconds in our description or omit?
1210 A string describing the duration represented by the input seconds briefly.
1214 Of course if we drop the seconds part the result is not precise.
1217 >>> describe_duration_briefly(182)
1220 >>> describe_duration_briefly(182, include_seconds=True)
1223 >>> describe_duration_briefly(100, include_seconds=True)
1226 describe_duration_briefly(1303200)
1230 days = divmod(seconds, constants.SECONDS_PER_DAY)
1231 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1232 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1236 descr = f"{int(days[0])}d "
1238 descr = descr + f"{int(hours[0])}h "
1239 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
1240 descr = descr + f"{int(minutes[0])}m "
1241 if minutes[1] > 0 and include_seconds:
1242 descr = descr + f"{int(minutes[1])}s"
1243 return descr.strip()
1246 def describe_timedelta_briefly(
1247 delta: datetime.timedelta, *, include_seconds: bool = False
1250 Describe a duration represented by a timedelta object.
1253 delta: the timedelta to describe briefly
1254 include_seconds: should we include the second delta?
1257 A string description of the input timedelta object.
1261 Milliseconds are never included in the string representation of
1262 durations even through they may be represented by an input
1263 `datetime.timedelta`. Not for use when this level of precision
1266 >>> d = datetime.timedelta(1, 600)
1267 >>> describe_timedelta_briefly(d)
1270 return describe_duration_briefly(
1271 int(delta.total_seconds()),
1272 include_seconds=include_seconds,
1273 ) # Note: drops milliseconds
1276 # The code to compute easter on a given year was copied from dateutil (pip
1277 # install python-dateutil) and dumped in here to avoid a dependency. Dateutil
1278 # is an Apache 2.0 LICENSE open source project:
1281 # Copyright 2017- dateutil contributors (see AUTHORS file)
1283 # Licensed under the Apache License, Version 2.0 (the "License");
1284 # you may not use this file except in compliance with the License.
1285 # You may obtain a copy of the License at
1287 # http://www.apache.org/licenses/LICENSE-2.0
1289 # Unless required by applicable law or agreed to in writing, software
1290 # distributed under the License is distributed on an "AS IS" BASIS,
1291 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1292 # See the License for the specific language governing permissions and
1293 # limitations under the License.
1295 # The above license applies to all contributions after 2017-12-01, as well as
1296 # all contributions that have been re-licensed (see AUTHORS file for the list of
1297 # contributors who have re-licensed their code).
1298 # --------------------------------------------------------------------------------
1299 # dateutil - Extensions to the standard Python datetime module.
1305 # Copyright (c) 2015- - dateutil contributors (see AUTHORS file)
1307 # All rights reserved.
1309 # Redistribution and use in source and binary forms, with or without
1310 # modification, are permitted provided that the following conditions are met:
1312 # * Redistributions of source code must retain the above copyright notice,
1313 # this list of conditions and the following disclaimer.
1314 # * Redistributions in binary form must reproduce the above copyright notice,
1315 # this list of conditions and the following disclaimer in the documentation
1316 # and/or other materials provided with the distribution.
1317 # * Neither the name of the copyright holder nor the names of its
1318 # contributors may be used to endorse or promote products derived from
1319 # this software without specific prior written permission.
1321 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
1322 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
1323 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
1324 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
1325 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
1326 # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
1327 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
1328 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
1329 # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
1330 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
1331 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
1333 # The above BSD License Applies to all code, even that also covered by Apache 2.0.
1340 def easter(year: int, method: int = EASTER_WESTERN):
1342 This method was ported from the work done by GM Arts,
1343 on top of the algorithm by Claus Tondering, which was
1344 based in part on the algorithm of Ouding (1940), as
1345 quoted in "Explanatory Supplement to the Astronomical
1346 Almanac", P. Kenneth Seidelmann, editor.
1348 This algorithm implements three different Easter
1349 calculation methods:
1351 1. Original calculation in Julian calendar, valid in
1353 2. Original method, with date converted to Gregorian
1354 calendar, valid in years 1583 to 4099
1355 3. Revised method, in Gregorian calendar, valid in
1356 years 1583 to 4099 as well
1358 These methods are represented by the constants:
1360 * ``EASTER_JULIAN = 1``
1361 * ``EASTER_ORTHODOX = 2``
1362 * ``EASTER_WESTERN = 3``
1364 The default method is method 3.
1366 More about the algorithm may be found at:
1368 `GM Arts: Easter Algorithms <http://www.gmarts.org/index.php?go=415>`_
1372 `The Calendar FAQ: Easter <https://www.tondering.dk/claus/cal/easter.php>`_
1375 ValueError if method argument is invalid
1379 if not (1 <= method <= 3):
1380 raise ValueError("invalid method")
1382 # g - Golden year - 1
1384 # h - (23 - Epact) mod 30
1385 # i - Number of days from March 21 to Paschal Full Moon
1386 # j - Weekday for PFM (0=Sunday, etc)
1387 # p - Number of days from March 21 to Sunday on or before PFM
1388 # (-6 to 28 methods 1 & 3, to 56 for method 2)
1389 # e - Extra days to add for method 2 (converting Julian
1390 # date to Gregorian date)
1397 i = (19 * g + 15) % 30
1398 j = (y + y // 4 + i) % 7
1400 # Extra dates to convert Julian to Gregorian date
1403 e = e + y // 100 - 16 - (y // 100 - 16) // 4
1407 h = (c - c // 4 - (8 * c + 13) // 25 + 19 * g + 15) % 30
1408 i = h - (h // 28) * (1 - (h // 28) * (29 // (h + 1)) * ((21 - g) // 11))
1409 j = (y + y // 4 + i + 2 - c + c // 4) % 7
1411 # p can be from -6 to 56 corresponding to dates 22 March to 23 May
1412 # (later dates apply to method 2, although 23 May never actually occurs)
1414 d = 1 + (p + 27 + (p + 6) // 40) % 31
1415 m = 3 + (p + 26) // 30
1416 return datetime.date(int(y), int(m), int(d))
1419 if __name__ == "__main__":