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:
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.
122 This doesn't change the hour, minute, second, day, month, etc...
123 of the input timezone. It simply adds a timezone to it. Adding
124 a timezone this way will likely change the instant to which the
125 datetime refers. See examples.
127 >>> now = datetime.datetime.now()
128 >>> is_timezone_aware(now)
131 >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
132 >>> is_timezone_aware(now_pacific)
135 >>> "US/Pacific" in now_pacific.tzinfo.__repr__()
138 >>> now.hour == now_pacific.hour
140 >>> now.minute == now_pacific.minute
145 # This doesn't work, tz requires a timezone naive dt. Two options
147 # 1. Use strip_timezone and try again.
148 # 2. Replace the timezone on your dt object via replace_timezone.
149 # Be aware that this changes the instant to which the dt refers
150 # and, further, can introduce weirdness like UTC offsets that
151 # are weird (e.g. not an even multiple of an hour, etc...)
152 if is_timezone_aware(dt):
156 f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
157 + 'depending on the semantics you want. See the pydocs / code.'
159 return dt.replace(tzinfo=tz)
162 def replace_timezone(
163 dt: datetime.datetime, tz: Optional[datetime.tzinfo]
164 ) -> datetime.datetime:
166 Replaces the timezone on a timezone aware datetime object directly
167 (leaving the year, month, day, hour, minute, second, micro,
168 etc... alone). The same as calling :meth:`strip_timezone` followed
169 by :meth:`add_timezone`.
171 Works with timezone aware and timezone naive dts but for the
172 latter it is probably better to use :meth:`add_timezone` or just
173 create it with a `tz` parameter. Using this can have weird side
174 effects like UTC offsets that are not an even multiple of an hour,
178 dt: the datetime whose timezone should be changed
182 The resulting datetime. Hour, minute, second, etc... are unmodified.
185 See also :meth:`add_timezone`, :meth:`strip_timezone`, :meth:`translate_timezone`.
189 This code isn't changing the hour, minute, second, day, month, etc...
190 of the datetime. It's just messing with the timezone. Changing
191 the timezone without changing the time causes the instant to which
192 the datetime refers to change. For example, if passed 7:01pm PST
193 and asked to make it EST, the result will be 7:01pm EST. See
196 >>> from pytz import UTC
197 >>> d = now_pacific()
198 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
201 >>> o = replace_timezone(d, UTC)
202 >>> o.tzinfo.tzname(o)
207 if is_timezone_aware(dt):
209 '%s already has a timezone; klobbering it anyway.\n Be aware that this operation changed the instant to which the object refers.',
212 return datetime.datetime(
224 return add_timezone(dt, tz)
229 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
230 """Replaces the timezone on a datetime.time directly without performing
234 t: the time to change the timezone on
235 tz: the new timezone desired
238 A time with hour, minute, second, etc... identical to the input
239 time but with timezone replaced.
243 This code isn't changing the hour, minute, second, etc...
244 of the time. It's just messing with the timezone. Changing
245 the timezone without changing the time causes the instant to which
246 the datetime refers to change. For example, if passed 7:01pm PST
247 and asked to make it EST, the result will be 7:01pm EST. See
250 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
254 >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
258 return t.replace(tzinfo=tz)
261 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
263 Translates dt into a different timezone by adjusting the year, month,
264 day, hour, minute, second, micro, etc... appropriately. The returned
265 dt is the same instant in another timezone.
268 dt: the datetime whose timezone should be translated.
269 tz: the desired timezone
272 A new datetime object that represents the same instant as the
273 input datetime but in the desired timezone. Modifies hour, minute,
274 seconds, day, etc... as necessary for the instant to be preserved.
275 For example, if you pass 11:01pm PST in and ask for it to be
276 translated to EST you would get 2:01am the next day EST back
279 See also :meth:`replace_timezone`, :meth:`strip_timezone`.
282 >>> d = now_pacific()
283 >>> d.tzinfo.tzname(d)[0] # Note: could be PST or PDT
286 >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
287 >>> o.tzinfo.tzname(o)[0] # Again, could be EST or EDT
291 >>> expected = h + 3 # Three hours later in E?T than P?T
292 >>> expected = expected % 24 # Handle edge case
293 >>> expected == o.hour
296 return dt.replace().astimezone(tz=tz)
299 def now() -> datetime.datetime:
301 What time is it? Result is a timezone naive datetime.
303 return datetime.datetime.now()
306 def now_pacific() -> datetime.datetime:
308 What time is it? Result in US/Pacific time (PST/PDT)
310 return datetime.datetime.now(pytz.timezone("US/Pacific"))
313 def date_to_datetime(date: datetime.date) -> datetime.datetime:
315 Given a date, return a datetime with hour/min/sec zero (midnight)
318 date: the date desired
321 A datetime with the same month, day, and year as the input
322 date and hours, minutes, seconds set to 12:00:00am.
325 >>> date_to_datetime(datetime.date(2021, 12, 25))
326 datetime.datetime(2021, 12, 25, 0, 0)
328 return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
331 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
333 Given a time, returns that time as a datetime with a date component
334 set based on the current date. If the time passed is timezone aware,
335 the resulting datetime will also be (and will use the same tzinfo).
336 If the time is timezone naive, the datetime returned will be too.
339 time: the time desired
342 datetime with hour, minute, second, timezone set to time and
343 day, month, year set to "today".
345 >>> t = datetime.time(13, 14, 0)
346 >>> d = now_pacific().date()
347 >>> dt = time_to_datetime_today(t)
354 >>> dt.tzinfo == t.tzinfo
357 >>> dt.tzinfo == None
360 >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
364 >>> dt = time_to_datetime_today(t)
365 >>> dt.tzinfo == None
370 return datetime.datetime.combine(now_pacific(), time, tz)
373 def date_and_time_to_datetime(
374 date: datetime.date, time: datetime.time
375 ) -> datetime.datetime:
377 Given a date and time, merge them and return a datetime.
380 date: the date component
381 time: the time component
384 A datetime with the time component set from time and the date
385 component set from date.
388 >>> d = datetime.date(2021, 12, 25)
389 >>> t = datetime.time(12, 30, 0, 0)
390 >>> date_and_time_to_datetime(d, t)
391 datetime.datetime(2021, 12, 25, 12, 30)
393 return datetime.datetime(
404 def datetime_to_date_and_time(
405 dt: datetime.datetime,
406 ) -> Tuple[datetime.date, datetime.time]:
407 """Return the component date and time objects of a datetime in a
408 Tuple given a datetime.
411 dt: the datetime to decompose
414 A tuple whose first element contains a datetime.date that holds
415 the day, month, year, etc... from the input dt and whose second
416 element contains a datetime.time with hour, minute, second, micros,
417 and timezone set from the input dt.
420 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
421 >>> (d, t) = datetime_to_date_and_time(dt)
423 datetime.date(2021, 12, 25)
425 datetime.time(12, 30)
427 return (dt.date(), dt.timetz())
430 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
431 """Return just the date part of a datetime.
437 A datetime.date with month, day and year set from input dt.
440 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
441 >>> datetime_to_date(dt)
442 datetime.date(2021, 12, 25)
444 return datetime_to_date_and_time(dt)[0]
447 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
448 """Return just the time part of a datetime.
454 A datetime.time with hour, minute, second, micros, and
455 timezone set from the input dt.
458 >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
459 >>> datetime_to_time(dt)
460 datetime.time(12, 30)
462 return datetime_to_date_and_time(dt)[1]
465 class TimeUnit(enum.IntEnum):
466 """An enum to represent units with which we can compute deltas."""
485 def is_valid(cls, value: Any):
488 value: a value to be checked
491 True is input value is a valid TimeUnit, False otherwise.
493 if isinstance(value, int):
494 return cls(value) is not None
495 elif isinstance(value, TimeUnit):
496 return cls(value.value) is not None
497 elif isinstance(value, str):
498 return cls.__members__[value] is not None
504 def n_timeunits_from_base(
505 count: int, unit: TimeUnit, base: datetime.datetime
506 ) -> datetime.datetime:
507 """Return a datetime that is N units before/after a base datetime.
510 - 3 Wednesdays from base datetime,
511 - 2 weeks from base date,
512 - 10 years before base datetime,
513 - 13 minutes after base datetime, etc...
516 count: signed number that indicates N units before/after the base.
517 unit: the timeunit that we are counting by.
518 base: a datetime representing the base date the result should be
522 A datetime that is count units before of after the base datetime.
526 To indicate before/after the base date, use a positive or
529 >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
531 The next (1) Monday from the base datetime:
533 >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
534 datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
536 Ten (10) years after the base datetime:
538 >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
539 datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
541 Fifty (50) working days (M..F, not counting holidays) after base datetime:
543 >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
544 datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
546 Fifty (50) days (including weekends and holidays) after base datetime:
548 >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
549 datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
551 Fifty (50) months before (note negative count) base datetime:
553 >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
554 datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
556 Fifty (50) hours after base datetime:
558 >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
559 datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
561 Fifty (50) minutes before base datetime:
563 >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
564 datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
566 Fifty (50) seconds from base datetime:
568 >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
569 datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
571 Next month corner case -- it will try to make Feb 31, 2022 then count
574 >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
575 >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
576 datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
578 Last month with the same corner case
580 >>> base = string_to_datetime("2022/03/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 assert TimeUnit.is_valid(unit)
589 if unit == TimeUnit.DAYS:
590 timedelta = datetime.timedelta(days=count)
591 return base + timedelta
594 elif unit == TimeUnit.HOURS:
595 timedelta = datetime.timedelta(hours=count)
596 return base + timedelta
598 # N minutes from base
599 elif unit == TimeUnit.MINUTES:
600 timedelta = datetime.timedelta(minutes=count)
601 return base + timedelta
603 # N seconds from base
604 elif unit == TimeUnit.SECONDS:
605 timedelta = datetime.timedelta(seconds=count)
606 return base + timedelta
608 # N workdays from base
609 elif unit == TimeUnit.WORKDAYS:
612 timedelta = datetime.timedelta(days=-1)
614 timedelta = datetime.timedelta(days=1)
615 skips = holidays.US(years=base.year).keys()
619 if base.year != old_year:
620 skips = holidays.US(years=base.year).keys()
623 and datetime.date(base.year, base.month, base.day) not in skips
629 elif unit == TimeUnit.WEEKS:
630 timedelta = datetime.timedelta(weeks=count)
631 base = base + timedelta
635 elif unit == TimeUnit.MONTHS:
636 month_term = count % 12
637 year_term = count // 12
638 new_month = base.month + month_term
642 new_year = base.year + year_term
646 ret = datetime.datetime(
662 elif unit == TimeUnit.YEARS:
663 new_year = base.year + count
664 return datetime.datetime(
686 raise ValueError(unit)
688 # N weekdays from base (e.g. 4 wednesdays from today)
689 direction = 1 if count > 0 else -1
691 timedelta = datetime.timedelta(days=direction)
695 if dow == unit.value and start != base:
699 base = base + timedelta
702 def get_format_string(
704 date_time_separator=" ",
705 include_timezone=True,
706 include_dayname=False,
707 use_month_abbrevs=False,
708 include_seconds=True,
709 include_fractional=False,
713 Helper to return a format string without looking up the documentation
717 date_time_separator: character or string to use between the date
719 include_timezone: whether or not the result should include a timezone
720 include_dayname: whether or not the result should incude the dayname
721 (e.g. Monday, Wednesday, etc...)
722 use_month_abbrevs: whether or not to abbreviate (e.g. Jan) or spell out
723 (e.g. January) month names.
724 include_seconds: whether or not to include seconds in time.
725 include_fractional: whether or not to include micros in time output.
726 twelve_hour: use twelve hour (with am/pm) or twenty four hour time format?
729 The format string for use with strftime that follows the given
732 >>> get_format_string()
733 '%Y/%m/%d %I:%M:%S%p%z'
735 >>> get_format_string(date_time_separator='@')
736 '%Y/%m/%d@%I:%M:%S%p%z'
738 >>> get_format_string(include_dayname=True)
739 '%a/%Y/%m/%d %I:%M:%S%p%z'
741 >>> get_format_string(include_dayname=True, twelve_hour=False)
742 '%a/%Y/%m/%d %H:%M:%S%z'
749 if use_month_abbrevs:
750 fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
752 fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
762 if include_fractional:
769 def datetime_to_string(
770 dt: datetime.datetime,
772 date_time_separator=" ",
773 include_timezone=True,
774 include_dayname=False,
775 use_month_abbrevs=False,
776 include_seconds=True,
777 include_fractional=False,
781 A nice way to convert a datetime into a string; arguably better than
782 just printing it and relying on it __repr__().
785 dt: the datetime to represent
786 date_time_separator: the character or string to separate the date and time
787 pieces of the representation.
788 include_timezone: should we include a timezone in the representation?
789 include_dayname: should we include the dayname (e.g. Mon) in
790 the representation or omit it?
791 use_month_abbrevs: should we name the month briefly (e.g. Jan) or spell
792 it out fully (e.g. January) in the representation?
793 include_seconds: should we include seconds in the time?
794 include_fractional: should we include micros in the time?
795 twelve_hour: should we use twelve or twenty-four hour time format?
797 >>> d = string_to_datetime(
798 ... "2021/09/10 11:24:51AM-0700",
801 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
802 >>> datetime_to_string(d)
803 '2021/09/10 11:24:51AM-0700'
804 >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
805 'Fri/2021/09/10 11:24AM-0700'
807 fstring = get_format_string(
808 date_time_separator=date_time_separator,
809 include_timezone=include_timezone,
810 include_dayname=include_dayname,
811 use_month_abbrevs=use_month_abbrevs,
812 include_seconds=include_seconds,
813 include_fractional=include_fractional,
814 twelve_hour=twelve_hour,
816 return dt.strftime(fstring).strip()
819 def string_to_datetime(
822 date_time_separator=" ",
823 include_timezone=True,
824 include_dayname=False,
825 use_month_abbrevs=False,
826 include_seconds=True,
827 include_fractional=False,
829 ) -> Tuple[datetime.datetime, str]:
830 """A nice way to convert a string into a datetime. Returns both the
831 datetime and the format string used to parse it. Also consider
832 :mod:`pyutils.datetimez.dateparse_utils` for a full parser alternative.
835 txt: the string to be converted into a datetime
836 date_time_separator: the character or string between the time and date
838 include_timezone: does the string include a timezone?
839 include_dayname: does the string include a dayname?
840 use_month_abbrevs: is the month abbreviated in the string (e.g. Feb)
841 or spelled out completely (e.g. February)?
842 include_seconds: does the string's time include seconds?
843 include_fractional: does the string's time include micros?
844 twelve_hour: is the string's time in twelve or twenty-four hour format?
847 A tuple containing the datetime parsed from string and the formatting
848 string used to parse it.
850 >>> d = string_to_datetime(
851 ... "2021/09/10 11:24:51AM-0700",
854 (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')
857 fstring = get_format_string(
858 date_time_separator=date_time_separator,
859 include_timezone=include_timezone,
860 include_dayname=include_dayname,
861 use_month_abbrevs=use_month_abbrevs,
862 include_seconds=include_seconds,
863 include_fractional=include_fractional,
864 twelve_hour=twelve_hour,
866 return (datetime.datetime.strptime(txt, fstring), fstring)
869 def timestamp() -> str:
872 A timestamp for right now in Pacific timezone.
874 ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
875 return datetime_to_string(ts, include_timezone=True)
879 dt: datetime.datetime,
881 include_seconds=True,
882 include_fractional=False,
883 include_timezone=False,
886 """A nice way to convert a datetime into a time (only) string.
887 This ignores the date part of the datetime completely.
890 dt: the datetime whose time to represent
891 include_seconds: should seconds be included in the output?
892 include_fractional: should micros be included in the output?
893 include_timezone: should timezone be included in the output?
894 twelve_hour: use twelve or twenty-four hour format?
897 A string representing the time of the input datetime.
899 >>> d = string_to_datetime(
900 ... "2021/09/10 11:24:51AM-0700",
903 datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
905 >>> time_to_string(d)
908 >>> time_to_string(d, include_seconds=False)
911 >>> time_to_string(d, include_seconds=False, include_timezone=True)
925 if include_fractional:
929 return dt.strftime(fstring).strip()
932 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
935 seconds: a count of seconds
938 A datetime.timedelta representing that count of seconds.
940 return datetime.timedelta(seconds=seconds)
943 MinuteOfDay = NewType("MinuteOfDay", int)
946 def minute_number(hour: int, minute: int) -> MinuteOfDay:
948 Convert hour:minute into minute number from start of day. That is,
949 if you imagine a day as a sequence of minutes from minute #0 up
950 to minute #1439, what minute number is, e.g., 6:52am?
953 hour: the hour to convert (0 <= hour <= 23)
954 minute: the minute to convert (0 <= minute <= 59)
957 The minute number requested. Raises `ValueError` on bad input.
959 >>> minute_number(0, 0)
962 >>> minute_number(9, 15)
965 >>> minute_number(23, 59)
968 if hour < 0 or hour > 23:
969 raise ValueError(f'Bad hour: {hour}. Expected 0 <= hour <= 23')
970 if minute < 0 or minute > 59:
971 raise ValueError(f'Bad minute: {minute}. Expected 0 <= minute <= 59')
972 return MinuteOfDay(hour * 60 + minute)
975 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
977 Convert a datetime's time component into a minute number (of
978 the day). Note that this ignores the date part of the datetime
979 and only uses the time part.
982 dt: the datetime whose time is to be converted
985 The minute number (of the day) that represents the input datetime's
988 >>> d = string_to_datetime(
989 ... "2021/09/10 11:24:51AM-0700",
992 >>> datetime_to_minute_number(d)
995 return minute_number(dt.hour, dt.minute)
998 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
1000 Convert a datetime.time into a minute number.
1003 t: a datetime.time to convert into a minute number.
1006 The minute number (of the day) of the input time.
1008 >>> t = datetime.time(5, 15)
1009 >>> time_to_minute_number(t)
1012 return minute_number(t.hour, t.minute)
1015 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
1017 Convert minute number from start of day into hour:minute am/pm
1021 minute_num: the minute number to convert into a string
1024 A string of the format "HH:MM[a|p]" that represents the time
1025 that the input minute_num refers to.
1027 >>> minute_number_to_time_string(315)
1030 >>> minute_number_to_time_string(684)
1033 hour = minute_num // 60
1034 minute = minute_num % 60
1043 return f"{hour:2}:{minute:02}{ampm}"
1046 def parse_duration(duration: str, raise_on_error=False) -> int:
1048 Parse a duration in string form into a delta seconds.
1051 duration: a string form duration, see examples.
1052 raise_on_error: should we raise on invalid input or just
1053 return a zero duration?
1056 A count of seconds represented by the input string.
1058 >>> parse_duration('15 days, 2 hours')
1061 >>> parse_duration('15d 2h')
1064 >>> parse_duration('100s')
1067 >>> parse_duration('3min 2sec')
1070 >>> parse_duration('recent')
1073 >>> parse_duration('recent', raise_on_error=True)
1074 Traceback (most recent call last):
1076 ValueError: recent is not a valid duration.
1078 if duration.isdigit():
1079 return int(duration)
1082 r'(\d+ *d[ays]*)* *(\d+ *h[ours]*)* *(\d+ *m[inutes]*)* *(\d+ *[seconds]*)',
1085 if not m and raise_on_error:
1086 raise ValueError(f'{duration} is not a valid duration.')
1089 m = re.search(r'(\d+) *d[ays]*', duration)
1091 seconds += int(m.group(1)) * 60 * 60 * 24
1092 m = re.search(r'(\d+) *h[ours]*', duration)
1094 seconds += int(m.group(1)) * 60 * 60
1095 m = re.search(r'(\d+) *m[inutes]*', duration)
1097 seconds += int(m.group(1)) * 60
1098 m = re.search(r'(\d+) *s[econds]*', duration)
1100 seconds += int(m.group(1))
1104 def describe_duration(seconds: int, *, include_seconds=False) -> str:
1106 Describe a duration represented as a count of seconds nicely.
1109 seconds: the number of seconds in the duration to be represented.
1110 include_seconds: should we include or drop the seconds part in
1115 Of course if we drop the seconds part the result is not precise.
1118 >>> describe_duration(182)
1121 >>> describe_duration(182, include_seconds=True)
1122 '3 minutes, and 2 seconds'
1124 >>> describe_duration(100, include_seconds=True)
1125 '1 minute, and 40 seconds'
1127 describe_duration(1303200)
1130 days = divmod(seconds, constants.SECONDS_PER_DAY)
1131 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1132 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1136 descr = f"{int(days[0])} days, "
1141 descr = descr + f"{int(hours[0])} hours, "
1143 descr = descr + "1 hour, "
1145 if not include_seconds and len(descr) > 0:
1146 descr = descr + "and "
1149 descr = descr + "1 minute"
1151 descr = descr + f"{int(minutes[0])} minutes"
1154 descr = descr + ', '
1156 descr = descr + 'and '
1159 descr = descr + '1 second'
1161 descr = descr + f'{s} seconds'
1165 def describe_timedelta(delta: datetime.timedelta) -> str:
1167 Describe a duration represented by a timedelta object.
1170 delta: the timedelta object that represents the duration to describe.
1173 A string representation of the input duration.
1177 Milliseconds are never included in the string representation of
1178 durations even through they may be represented by an input
1179 `datetime.timedelta`. Not for use when this level of precision
1182 >>> d = datetime.timedelta(1, 600)
1183 >>> describe_timedelta(d)
1184 '1 day, and 10 minutes'
1186 return describe_duration(int(delta.total_seconds())) # Note: drops milliseconds
1189 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
1191 Describe a duration briefly.
1194 seconds: the number of seconds in the duration to describe.
1195 include_seconds: should we include seconds in our description or omit?
1198 A string describing the duration represented by the input seconds briefly.
1202 Of course if we drop the seconds part the result is not precise.
1205 >>> describe_duration_briefly(182)
1208 >>> describe_duration_briefly(182, include_seconds=True)
1211 >>> describe_duration_briefly(100, include_seconds=True)
1214 describe_duration_briefly(1303200)
1218 days = divmod(seconds, constants.SECONDS_PER_DAY)
1219 hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1220 minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1224 descr = f'{int(days[0])}d '
1226 descr = descr + f'{int(hours[0])}h '
1227 if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
1228 descr = descr + f'{int(minutes[0])}m '
1229 if minutes[1] > 0 and include_seconds:
1230 descr = descr + f'{int(minutes[1])}s'
1231 return descr.strip()
1234 def describe_timedelta_briefly(
1235 delta: datetime.timedelta, *, include_seconds=False
1238 Describe a duration represented by a timedelta object.
1241 delta: the timedelta to describe briefly
1244 A string description of the input timedelta object.
1248 Milliseconds are never included in the string representation of
1249 durations even through they may be represented by an input
1250 `datetime.timedelta`. Not for use when this level of precision
1253 >>> d = datetime.timedelta(1, 600)
1254 >>> describe_timedelta_briefly(d)
1257 return describe_duration_briefly(
1258 int(delta.total_seconds()),
1259 include_seconds=include_seconds,
1260 ) # Note: drops milliseconds
1263 if __name__ == '__main__':