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