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