At python3.10 importlib.abc is a separate module that must be imported.
[python_utils.git] / datetime_utils.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """Utilities related to dates, times, and datetimes."""
6
7 import datetime
8 import enum
9 import logging
10 import re
11 from typing import Any, NewType, Optional, Tuple
12
13 import holidays  # type: ignore
14 import pytz
15
16 import constants
17
18 logger = logging.getLogger(__name__)
19
20
21 def is_timezone_aware(dt: datetime.datetime) -> bool:
22     """Returns true if the datetime argument is timezone aware or
23     False if not.
24
25     See: https://docs.python.org/3/library/datetime.html
26     #determining-if-an-object-is-aware-or-naive
27
28     Args:
29         dt: The datetime object to check
30
31     >>> is_timezone_aware(datetime.datetime.now())
32     False
33
34     >>> is_timezone_aware(now_pacific())
35     True
36
37     """
38     return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
39
40
41 def is_timezone_naive(dt: datetime.datetime) -> bool:
42     """Inverse of is_timezone_aware -- returns true if the dt argument
43     is timezone naive.
44
45     See: https://docs.python.org/3/library/datetime.html
46     #determining-if-an-object-is-aware-or-naive
47
48     Args:
49         dt: The datetime object to check
50
51     >>> is_timezone_naive(datetime.datetime.now())
52     True
53
54     >>> is_timezone_naive(now_pacific())
55     False
56
57     """
58     return not is_timezone_aware(dt)
59
60
61 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
62     """Remove the timezone from a datetime.
63
64     .. warning::
65
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.
70
71     >>> now = now_pacific()
72     >>> now.tzinfo == None
73     False
74
75     >>> dt = strip_timezone(now)
76     >>> dt == now
77     False
78
79     >>> dt.tzinfo == None
80     True
81
82     >>> dt.hour == now.hour
83     True
84
85     """
86     if is_timezone_naive(dt):
87         return dt
88     return replace_timezone(dt, None)
89
90
91 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
92     """
93     Adds a timezone to a timezone naive datetime.  This does not
94     change the instant to which the timestamp refers.  See also:
95     replace_timezone.
96
97     >>> now = datetime.datetime.now()
98     >>> is_timezone_aware(now)
99     False
100
101     >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
102     >>> is_timezone_aware(now_pacific)
103     True
104
105     >>> now.hour == now_pacific.hour
106     True
107     >>> now.minute == now_pacific.minute
108     True
109
110     """
111
112     # This doesn't work, tz requires a timezone naive dt.  Two options
113     # here:
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):
120         if dt.tzinfo == tz:
121             return dt
122         raise Exception(
123             f'{dt} is already timezone aware; use replace_timezone or translate_timezone '
124             + 'depending on the semantics you want.  See the pydocs / code.'
125         )
126     return dt.replace(tzinfo=tz)
127
128
129 def replace_timezone(dt: datetime.datetime, tz: Optional[datetime.tzinfo]) -> datetime.datetime:
130     """Replaces the timezone on a timezone aware datetime object directly
131     (leaving the year, month, day, hour, minute, second, micro,
132     etc... alone).
133
134     Works with timezone aware and timezone naive dts but for the
135     latter it is probably better to use add_timezone or just create it
136     with a tz parameter.  Using this can have weird side effects like
137     UTC offsets that are not an even multiple of an hour, etc...
138
139     .. warning::
140
141         This changes the instant to which this dt refers.
142
143     >>> from pytz import UTC
144     >>> d = now_pacific()
145     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
146     'P'
147     >>> h = d.hour
148     >>> o = replace_timezone(d, UTC)
149     >>> o.tzinfo.tzname(o)
150     'UTC'
151     >>> o.hour == h
152     True
153
154     """
155     if is_timezone_aware(dt):
156         logger.warning(
157             '%s already has a timezone; klobbering it anyway.\n  Be aware that this operation changed the instant to which the object refers.',
158             dt,
159         )
160         return datetime.datetime(
161             dt.year,
162             dt.month,
163             dt.day,
164             dt.hour,
165             dt.minute,
166             dt.second,
167             dt.microsecond,
168             tzinfo=tz,
169         )
170     else:
171         if tz:
172             return add_timezone(dt, tz)
173         else:
174             return dt
175
176
177 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
178     """Replaces the timezone on a datetime.time directly without performing
179     any translation.
180
181     .. warning::
182
183         Note that, as above, this will change the instant to
184         which the time refers.
185
186     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
187     >>> t.tzname()
188     'UTC'
189
190     >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
191     >>> t.tzname()
192     'US/Pacific'
193     """
194     return t.replace(tzinfo=tz)
195
196
197 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
198     """
199     Translates dt into a different timezone by adjusting the year, month,
200     day, hour, minute, second, micro, etc... appropriately.  The returned
201     dt is the same instant in another timezone.
202
203     >>> from pytz import UTC
204     >>> d = now_pacific()
205     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
206     'P'
207     >>> h = d.hour
208     >>> o = translate_timezone(d, UTC)
209     >>> o.tzinfo.tzname(o)
210     'UTC'
211     >>> o.hour == h
212     False
213
214     """
215     return dt.replace(tzinfo=None).astimezone(tz=tz)
216
217
218 def now() -> datetime.datetime:
219     """
220     What time is it?  Result is a timezone naive datetime.
221     """
222     return datetime.datetime.now()
223
224
225 def now_pacific() -> datetime.datetime:
226     """
227     What time is it?  Result in US/Pacific time (PST/PDT)
228     """
229     return datetime.datetime.now(pytz.timezone("US/Pacific"))
230
231
232 def date_to_datetime(date: datetime.date) -> datetime.datetime:
233     """
234     Given a date, return a datetime with hour/min/sec zero (midnight)
235
236     >>> import datetime
237     >>> date_to_datetime(datetime.date(2021, 12, 25))
238     datetime.datetime(2021, 12, 25, 0, 0)
239
240     """
241     return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
242
243
244 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
245     """
246     Given a time, returns that time as a datetime with a date component
247     set based on the current date.  If the time passed is timezone aware,
248     the resulting datetime will also be (and will use the same tzinfo).
249     If the time is timezone naive, the datetime returned will be too.
250
251     >>> t = datetime.time(13, 14, 0)
252     >>> d = now_pacific().date()
253     >>> dt = time_to_datetime_today(t)
254     >>> dt.date() == d
255     True
256
257     >>> dt.time() == t
258     True
259
260     >>> dt.tzinfo == t.tzinfo
261     True
262
263     >>> dt.tzinfo == None
264     True
265
266     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
267     >>> t.tzinfo == None
268     False
269
270     >>> dt = time_to_datetime_today(t)
271     >>> dt.tzinfo == None
272     False
273
274     """
275     tz = time.tzinfo
276     return datetime.datetime.combine(now_pacific(), time, tz)
277
278
279 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
280     """
281     Given a date and time, merge them and return a datetime.
282
283     >>> import datetime
284     >>> d = datetime.date(2021, 12, 25)
285     >>> t = datetime.time(12, 30, 0, 0)
286     >>> date_and_time_to_datetime(d, t)
287     datetime.datetime(2021, 12, 25, 12, 30)
288
289     """
290     return datetime.datetime(
291         date.year,
292         date.month,
293         date.day,
294         time.hour,
295         time.minute,
296         time.second,
297         time.microsecond,
298     )
299
300
301 def datetime_to_date_and_time(
302     dt: datetime.datetime,
303 ) -> Tuple[datetime.date, datetime.time]:
304     """Return the component date and time objects of a datetime in a
305     Tuple given a datetime.
306
307     >>> import datetime
308     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
309     >>> (d, t) = datetime_to_date_and_time(dt)
310     >>> d
311     datetime.date(2021, 12, 25)
312     >>> t
313     datetime.time(12, 30)
314
315     """
316     return (dt.date(), dt.timetz())
317
318
319 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
320     """Return just the date part of a datetime.
321
322     >>> import datetime
323     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
324     >>> datetime_to_date(dt)
325     datetime.date(2021, 12, 25)
326
327     """
328     return datetime_to_date_and_time(dt)[0]
329
330
331 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
332     """Return just the time part of a datetime.
333
334     >>> import datetime
335     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
336     >>> datetime_to_time(dt)
337     datetime.time(12, 30)
338
339     """
340     return datetime_to_date_and_time(dt)[1]
341
342
343 class TimeUnit(enum.IntEnum):
344     """An enum to represent units with which we can compute deltas."""
345
346     MONDAYS = 0
347     TUESDAYS = 1
348     WEDNESDAYS = 2
349     THURSDAYS = 3
350     FRIDAYS = 4
351     SATURDAYS = 5
352     SUNDAYS = 6
353     SECONDS = 7
354     MINUTES = 8
355     HOURS = 9
356     DAYS = 10
357     WORKDAYS = 11
358     WEEKS = 12
359     MONTHS = 13
360     YEARS = 14
361
362     @classmethod
363     def is_valid(cls, value: Any):
364         if isinstance(value, int):
365             return cls(value) is not None
366         elif isinstance(value, TimeUnit):
367             return cls(value.value) is not None
368         elif isinstance(value, str):
369             return cls.__members__[value] is not None
370         else:
371             print(type(value))
372             return False
373
374
375 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
376     """Return a datetime that is N units before/after a base datetime.
377     e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
378     years before base datetime, 13 minutes after base datetime, etc...
379     Note: to indicate before/after the base date, use a positive or
380     negative count.
381
382     >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
383
384     The next (1) Monday from the base datetime:
385     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
386     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
387
388     Ten (10) years after the base datetime:
389     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
390     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
391
392     Fifty (50) working days (M..F, not counting holidays) after base datetime:
393     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
394     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
395
396     Fifty (50) days (including weekends and holidays) after base datetime:
397     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
398     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
399
400     Fifty (50) months before (note negative count) base datetime:
401     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
402     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
403
404     Fifty (50) hours after base datetime:
405     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
406     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
407
408     Fifty (50) minutes before base datetime:
409     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
410     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
411
412     Fifty (50) seconds from base datetime:
413     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
414     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
415
416     Next month corner case -- it will try to make Feb 31, 2022 then count
417     backwards.
418     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
419     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
420     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
421
422     Last month with the same corner case
423     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
424     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
425     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
426
427     """
428     assert TimeUnit.is_valid(unit)
429     if count == 0:
430         return base
431
432     # N days from base
433     if unit == TimeUnit.DAYS:
434         timedelta = datetime.timedelta(days=count)
435         return base + timedelta
436
437     # N hours from base
438     elif unit == TimeUnit.HOURS:
439         timedelta = datetime.timedelta(hours=count)
440         return base + timedelta
441
442     # N minutes from base
443     elif unit == TimeUnit.MINUTES:
444         timedelta = datetime.timedelta(minutes=count)
445         return base + timedelta
446
447     # N seconds from base
448     elif unit == TimeUnit.SECONDS:
449         timedelta = datetime.timedelta(seconds=count)
450         return base + timedelta
451
452     # N workdays from base
453     elif unit == TimeUnit.WORKDAYS:
454         if count < 0:
455             count = abs(count)
456             timedelta = datetime.timedelta(days=-1)
457         else:
458             timedelta = datetime.timedelta(days=1)
459         skips = holidays.US(years=base.year).keys()
460         while count > 0:
461             old_year = base.year
462             base += timedelta
463             if base.year != old_year:
464                 skips = holidays.US(years=base.year).keys()
465             if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
466                 count -= 1
467         return base
468
469     # N weeks from base
470     elif unit == TimeUnit.WEEKS:
471         timedelta = datetime.timedelta(weeks=count)
472         base = base + timedelta
473         return base
474
475     # N months from base
476     elif unit == TimeUnit.MONTHS:
477         month_term = count % 12
478         year_term = count // 12
479         new_month = base.month + month_term
480         if new_month > 12:
481             new_month %= 12
482             year_term += 1
483         new_year = base.year + year_term
484         day = base.day
485         while True:
486             try:
487                 ret = datetime.datetime(
488                     new_year,
489                     new_month,
490                     day,
491                     base.hour,
492                     base.minute,
493                     base.second,
494                     base.microsecond,
495                     base.tzinfo,
496                 )
497                 break
498             except ValueError:
499                 day -= 1
500         return ret
501
502     # N years from base
503     elif unit == TimeUnit.YEARS:
504         new_year = base.year + count
505         return datetime.datetime(
506             new_year,
507             base.month,
508             base.day,
509             base.hour,
510             base.minute,
511             base.second,
512             base.microsecond,
513             base.tzinfo,
514         )
515
516     if unit not in set(
517         [
518             TimeUnit.MONDAYS,
519             TimeUnit.TUESDAYS,
520             TimeUnit.WEDNESDAYS,
521             TimeUnit.THURSDAYS,
522             TimeUnit.FRIDAYS,
523             TimeUnit.SATURDAYS,
524             TimeUnit.SUNDAYS,
525         ]
526     ):
527         raise ValueError(unit)
528
529     # N weekdays from base (e.g. 4 wednesdays from today)
530     direction = 1 if count > 0 else -1
531     count = abs(count)
532     timedelta = datetime.timedelta(days=direction)
533     start = base
534     while True:
535         dow = base.weekday()
536         if dow == unit.value and start != base:
537             count -= 1
538             if count == 0:
539                 return base
540         base = base + timedelta
541
542
543 def get_format_string(
544     *,
545     date_time_separator=" ",
546     include_timezone=True,
547     include_dayname=False,
548     use_month_abbrevs=False,
549     include_seconds=True,
550     include_fractional=False,
551     twelve_hour=True,
552 ) -> str:
553     """
554     Helper to return a format string without looking up the documentation
555     for strftime.
556
557     >>> get_format_string()
558     '%Y/%m/%d %I:%M:%S%p%z'
559
560     >>> get_format_string(date_time_separator='@')
561     '%Y/%m/%d@%I:%M:%S%p%z'
562
563     >>> get_format_string(include_dayname=True)
564     '%a/%Y/%m/%d %I:%M:%S%p%z'
565
566     >>> get_format_string(include_dayname=True, twelve_hour=False)
567     '%a/%Y/%m/%d %H:%M:%S%z'
568
569     """
570     fstring = ""
571     if include_dayname:
572         fstring += "%a/"
573
574     if use_month_abbrevs:
575         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
576     else:
577         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
578     if twelve_hour:
579         fstring += "%I:%M"
580         if include_seconds:
581             fstring += ":%S"
582         fstring += "%p"
583     else:
584         fstring += "%H:%M"
585         if include_seconds:
586             fstring += ":%S"
587     if include_fractional:
588         fstring += ".%f"
589     if include_timezone:
590         fstring += "%z"
591     return fstring
592
593
594 def datetime_to_string(
595     dt: datetime.datetime,
596     *,
597     date_time_separator=" ",
598     include_timezone=True,
599     include_dayname=False,
600     use_month_abbrevs=False,
601     include_seconds=True,
602     include_fractional=False,
603     twelve_hour=True,
604 ) -> str:
605     """
606     A nice way to convert a datetime into a string; arguably better than
607     just printing it and relying on it __repr__().
608
609     >>> d = string_to_datetime(
610     ...                        "2021/09/10 11:24:51AM-0700",
611     ...                       )[0]
612     >>> d
613     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
614     >>> datetime_to_string(d)
615     '2021/09/10 11:24:51AM-0700'
616     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
617     'Fri/2021/09/10 11:24AM-0700'
618
619     """
620     fstring = get_format_string(
621         date_time_separator=date_time_separator,
622         include_timezone=include_timezone,
623         include_dayname=include_dayname,
624         use_month_abbrevs=use_month_abbrevs,
625         include_seconds=include_seconds,
626         include_fractional=include_fractional,
627         twelve_hour=twelve_hour,
628     )
629     return dt.strftime(fstring).strip()
630
631
632 def string_to_datetime(
633     txt: str,
634     *,
635     date_time_separator=" ",
636     include_timezone=True,
637     include_dayname=False,
638     use_month_abbrevs=False,
639     include_seconds=True,
640     include_fractional=False,
641     twelve_hour=True,
642 ) -> Tuple[datetime.datetime, str]:
643     """A nice way to convert a string into a datetime.  Returns both the
644     datetime and the format string used to parse it.  Also consider
645     dateparse.dateparse_utils for a full parser alternative.
646
647     >>> d = string_to_datetime(
648     ...                        "2021/09/10 11:24:51AM-0700",
649     ...                       )
650     >>> d
651     (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')
652
653     """
654     fstring = get_format_string(
655         date_time_separator=date_time_separator,
656         include_timezone=include_timezone,
657         include_dayname=include_dayname,
658         use_month_abbrevs=use_month_abbrevs,
659         include_seconds=include_seconds,
660         include_fractional=include_fractional,
661         twelve_hour=twelve_hour,
662     )
663     return (datetime.datetime.strptime(txt, fstring), fstring)
664
665
666 def timestamp() -> str:
667     """Return a timestamp for right now in Pacific timezone."""
668     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
669     return datetime_to_string(ts, include_timezone=True)
670
671
672 def time_to_string(
673     dt: datetime.datetime,
674     *,
675     include_seconds=True,
676     include_fractional=False,
677     include_timezone=False,
678     twelve_hour=True,
679 ) -> str:
680     """A nice way to convert a datetime into a time (only) string.
681     This ignores the date part of the datetime.
682
683     >>> d = string_to_datetime(
684     ...                        "2021/09/10 11:24:51AM-0700",
685     ...                       )[0]
686     >>> d
687     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
688
689     >>> time_to_string(d)
690     '11:24:51AM'
691
692     >>> time_to_string(d, include_seconds=False)
693     '11:24AM'
694
695     >>> time_to_string(d, include_seconds=False, include_timezone=True)
696     '11:24AM-0700'
697
698     """
699     fstring = ""
700     if twelve_hour:
701         fstring += "%l:%M"
702         if include_seconds:
703             fstring += ":%S"
704         fstring += "%p"
705     else:
706         fstring += "%H:%M"
707         if include_seconds:
708             fstring += ":%S"
709     if include_fractional:
710         fstring += ".%f"
711     if include_timezone:
712         fstring += "%z"
713     return dt.strftime(fstring).strip()
714
715
716 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
717     """Convert a delta in seconds into a timedelta."""
718     return datetime.timedelta(seconds=seconds)
719
720
721 MinuteOfDay = NewType("MinuteOfDay", int)
722
723
724 def minute_number(hour: int, minute: int) -> MinuteOfDay:
725     """
726     Convert hour:minute into minute number from start of day.
727
728     >>> minute_number(0, 0)
729     0
730
731     >>> minute_number(9, 15)
732     555
733
734     >>> minute_number(23, 59)
735     1439
736
737     """
738     return MinuteOfDay(hour * 60 + minute)
739
740
741 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
742     """
743     Convert a datetime into a minute number (of the day).  Note that
744     this ignores the date part of the datetime and only uses the time
745     part.
746
747     >>> d = string_to_datetime(
748     ...                        "2021/09/10 11:24:51AM-0700",
749     ...                       )[0]
750
751     >>> datetime_to_minute_number(d)
752     684
753
754     """
755     return minute_number(dt.hour, dt.minute)
756
757
758 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
759     """
760     Convert a datetime.time into a minute number.
761
762     >>> t = datetime.time(5, 15)
763     >>> time_to_minute_number(t)
764     315
765
766     """
767     return minute_number(t.hour, t.minute)
768
769
770 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
771     """
772     Convert minute number from start of day into hour:minute am/pm
773     string.
774
775     >>> minute_number_to_time_string(315)
776     ' 5:15a'
777
778     >>> minute_number_to_time_string(684)
779     '11:24a'
780
781     """
782     hour = minute_num // 60
783     minute = minute_num % 60
784     ampm = "a"
785     if hour > 12:
786         hour -= 12
787         ampm = "p"
788     if hour == 12:
789         ampm = "p"
790     if hour == 0:
791         hour = 12
792     return f"{hour:2}:{minute:02}{ampm}"
793
794
795 def parse_duration(duration: str) -> int:
796     """
797     Parse a duration in string form into a delta seconds.
798
799     >>> parse_duration('15 days, 2 hours')
800     1303200
801
802     >>> parse_duration('15d 2h')
803     1303200
804
805     >>> parse_duration('100s')
806     100
807
808     >>> parse_duration('3min 2sec')
809     182
810
811     """
812     if duration.isdigit():
813         return int(duration)
814     seconds = 0
815     m = re.search(r'(\d+) *d[ays]*', duration)
816     if m is not None:
817         seconds += int(m.group(1)) * 60 * 60 * 24
818     m = re.search(r'(\d+) *h[ours]*', duration)
819     if m is not None:
820         seconds += int(m.group(1)) * 60 * 60
821     m = re.search(r'(\d+) *m[inutes]*', duration)
822     if m is not None:
823         seconds += int(m.group(1)) * 60
824     m = re.search(r'(\d+) *s[econds]*', duration)
825     if m is not None:
826         seconds += int(m.group(1))
827     return seconds
828
829
830 def describe_duration(seconds: int, *, include_seconds=False) -> str:
831     """
832     Describe a duration represented as a count of seconds nicely.
833
834     >>> describe_duration(182)
835     '3 minutes'
836
837     >>> describe_duration(182, include_seconds=True)
838     '3 minutes, and 2 seconds'
839
840     >>> describe_duration(100, include_seconds=True)
841     '1 minute, and 40 seconds'
842
843     describe_duration(1303200)
844     '15 days, 2 hours'
845
846     """
847     days = divmod(seconds, constants.SECONDS_PER_DAY)
848     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
849     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
850
851     descr = ""
852     if days[0] > 1:
853         descr = f"{int(days[0])} days, "
854     elif days[0] == 1:
855         descr = "1 day, "
856
857     if hours[0] > 1:
858         descr = descr + f"{int(hours[0])} hours, "
859     elif hours[0] == 1:
860         descr = descr + "1 hour, "
861
862     if not include_seconds and len(descr) > 0:
863         descr = descr + "and "
864
865     if minutes[0] == 1:
866         descr = descr + "1 minute"
867     else:
868         descr = descr + f"{int(minutes[0])} minutes"
869
870     if include_seconds:
871         descr = descr + ', '
872         if len(descr) > 0:
873             descr = descr + 'and '
874         s = minutes[1]
875         if s == 1:
876             descr = descr + '1 second'
877         else:
878             descr = descr + f'{s} seconds'
879     return descr
880
881
882 def describe_timedelta(delta: datetime.timedelta) -> str:
883     """
884     Describe a duration represented by a timedelta object.
885
886     >>> d = datetime.timedelta(1, 600)
887     >>> describe_timedelta(d)
888     '1 day, and 10 minutes'
889
890     """
891     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
892
893
894 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
895     """
896     Describe a duration briefly.
897
898     >>> describe_duration_briefly(182)
899     '3m'
900
901     >>> describe_duration_briefly(182, include_seconds=True)
902     '3m 2s'
903
904     >>> describe_duration_briefly(100, include_seconds=True)
905     '1m 40s'
906
907     describe_duration_briefly(1303200)
908     '15d 2h'
909
910     """
911     days = divmod(seconds, constants.SECONDS_PER_DAY)
912     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
913     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
914
915     descr = ''
916     if days[0] > 0:
917         descr = f'{int(days[0])}d '
918     if hours[0] > 0:
919         descr = descr + f'{int(hours[0])}h '
920     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
921         descr = descr + f'{int(minutes[0])}m '
922     if minutes[1] > 0 and include_seconds:
923         descr = descr + f'{int(minutes[1])}s'
924     return descr.strip()
925
926
927 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
928     """
929     Describe a duration represented by a timedelta object.
930
931     >>> d = datetime.timedelta(1, 600)
932     >>> describe_timedelta_briefly(d)
933     '1d 10m'
934
935     """
936     return describe_duration_briefly(int(delta.total_seconds()))  # Note: drops milliseconds
937
938
939 if __name__ == '__main__':
940     import doctest
941
942     doctest.testmod()