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