Migration from old pyutilz package name (which, in turn, came from
[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     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
395     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
396
397     Ten (10) years after the base datetime:
398     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
399     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
400
401     Fifty (50) working days (M..F, not counting holidays) after base datetime:
402     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
403     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
404
405     Fifty (50) days (including weekends and holidays) after base datetime:
406     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
407     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
408
409     Fifty (50) months before (note negative count) base datetime:
410     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
411     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
412
413     Fifty (50) hours after base datetime:
414     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
415     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
416
417     Fifty (50) minutes before base datetime:
418     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
419     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
420
421     Fifty (50) seconds from base datetime:
422     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
423     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
424
425     Next month corner case -- it will try to make Feb 31, 2022 then count
426     backwards.
427     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
428     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
429     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
430
431     Last month with the same corner case
432     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
433     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
434     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
435
436     """
437     assert TimeUnit.is_valid(unit)
438     if count == 0:
439         return base
440
441     # N days from base
442     if unit == TimeUnit.DAYS:
443         timedelta = datetime.timedelta(days=count)
444         return base + timedelta
445
446     # N hours from base
447     elif unit == TimeUnit.HOURS:
448         timedelta = datetime.timedelta(hours=count)
449         return base + timedelta
450
451     # N minutes from base
452     elif unit == TimeUnit.MINUTES:
453         timedelta = datetime.timedelta(minutes=count)
454         return base + timedelta
455
456     # N seconds from base
457     elif unit == TimeUnit.SECONDS:
458         timedelta = datetime.timedelta(seconds=count)
459         return base + timedelta
460
461     # N workdays from base
462     elif unit == TimeUnit.WORKDAYS:
463         if count < 0:
464             count = abs(count)
465             timedelta = datetime.timedelta(days=-1)
466         else:
467             timedelta = datetime.timedelta(days=1)
468         skips = holidays.US(years=base.year).keys()
469         while count > 0:
470             old_year = base.year
471             base += timedelta
472             if base.year != old_year:
473                 skips = holidays.US(years=base.year).keys()
474             if (
475                 base.weekday() < 5
476                 and datetime.date(base.year, base.month, base.day) not in skips
477             ):
478                 count -= 1
479         return base
480
481     # N weeks from base
482     elif unit == TimeUnit.WEEKS:
483         timedelta = datetime.timedelta(weeks=count)
484         base = base + timedelta
485         return base
486
487     # N months from base
488     elif unit == TimeUnit.MONTHS:
489         month_term = count % 12
490         year_term = count // 12
491         new_month = base.month + month_term
492         if new_month > 12:
493             new_month %= 12
494             year_term += 1
495         new_year = base.year + year_term
496         day = base.day
497         while True:
498             try:
499                 ret = datetime.datetime(
500                     new_year,
501                     new_month,
502                     day,
503                     base.hour,
504                     base.minute,
505                     base.second,
506                     base.microsecond,
507                     base.tzinfo,
508                 )
509                 break
510             except ValueError:
511                 day -= 1
512         return ret
513
514     # N years from base
515     elif unit == TimeUnit.YEARS:
516         new_year = base.year + count
517         return datetime.datetime(
518             new_year,
519             base.month,
520             base.day,
521             base.hour,
522             base.minute,
523             base.second,
524             base.microsecond,
525             base.tzinfo,
526         )
527
528     if unit not in set(
529         [
530             TimeUnit.MONDAYS,
531             TimeUnit.TUESDAYS,
532             TimeUnit.WEDNESDAYS,
533             TimeUnit.THURSDAYS,
534             TimeUnit.FRIDAYS,
535             TimeUnit.SATURDAYS,
536             TimeUnit.SUNDAYS,
537         ]
538     ):
539         raise ValueError(unit)
540
541     # N weekdays from base (e.g. 4 wednesdays from today)
542     direction = 1 if count > 0 else -1
543     count = abs(count)
544     timedelta = datetime.timedelta(days=direction)
545     start = base
546     while True:
547         dow = base.weekday()
548         if dow == unit.value and start != base:
549             count -= 1
550             if count == 0:
551                 return base
552         base = base + timedelta
553
554
555 def get_format_string(
556     *,
557     date_time_separator=" ",
558     include_timezone=True,
559     include_dayname=False,
560     use_month_abbrevs=False,
561     include_seconds=True,
562     include_fractional=False,
563     twelve_hour=True,
564 ) -> str:
565     """
566     Helper to return a format string without looking up the documentation
567     for strftime.
568
569     >>> get_format_string()
570     '%Y/%m/%d %I:%M:%S%p%z'
571
572     >>> get_format_string(date_time_separator='@')
573     '%Y/%m/%d@%I:%M:%S%p%z'
574
575     >>> get_format_string(include_dayname=True)
576     '%a/%Y/%m/%d %I:%M:%S%p%z'
577
578     >>> get_format_string(include_dayname=True, twelve_hour=False)
579     '%a/%Y/%m/%d %H:%M:%S%z'
580
581     """
582     fstring = ""
583     if include_dayname:
584         fstring += "%a/"
585
586     if use_month_abbrevs:
587         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
588     else:
589         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
590     if twelve_hour:
591         fstring += "%I:%M"
592         if include_seconds:
593             fstring += ":%S"
594         fstring += "%p"
595     else:
596         fstring += "%H:%M"
597         if include_seconds:
598             fstring += ":%S"
599     if include_fractional:
600         fstring += ".%f"
601     if include_timezone:
602         fstring += "%z"
603     return fstring
604
605
606 def datetime_to_string(
607     dt: datetime.datetime,
608     *,
609     date_time_separator=" ",
610     include_timezone=True,
611     include_dayname=False,
612     use_month_abbrevs=False,
613     include_seconds=True,
614     include_fractional=False,
615     twelve_hour=True,
616 ) -> str:
617     """
618     A nice way to convert a datetime into a string; arguably better than
619     just printing it and relying on it __repr__().
620
621     >>> d = string_to_datetime(
622     ...                        "2021/09/10 11:24:51AM-0700",
623     ...                       )[0]
624     >>> d
625     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
626     >>> datetime_to_string(d)
627     '2021/09/10 11:24:51AM-0700'
628     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
629     'Fri/2021/09/10 11:24AM-0700'
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 dt.strftime(fstring).strip()
642
643
644 def string_to_datetime(
645     txt: str,
646     *,
647     date_time_separator=" ",
648     include_timezone=True,
649     include_dayname=False,
650     use_month_abbrevs=False,
651     include_seconds=True,
652     include_fractional=False,
653     twelve_hour=True,
654 ) -> Tuple[datetime.datetime, str]:
655     """A nice way to convert a string into a datetime.  Returns both the
656     datetime and the format string used to parse it.  Also consider
657     dateparse.dateparse_utils for a full parser alternative.
658
659     >>> d = string_to_datetime(
660     ...                        "2021/09/10 11:24:51AM-0700",
661     ...                       )
662     >>> d
663     (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')
664
665     """
666     fstring = get_format_string(
667         date_time_separator=date_time_separator,
668         include_timezone=include_timezone,
669         include_dayname=include_dayname,
670         use_month_abbrevs=use_month_abbrevs,
671         include_seconds=include_seconds,
672         include_fractional=include_fractional,
673         twelve_hour=twelve_hour,
674     )
675     return (datetime.datetime.strptime(txt, fstring), fstring)
676
677
678 def timestamp() -> str:
679     """Return a timestamp for right now in Pacific timezone."""
680     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
681     return datetime_to_string(ts, include_timezone=True)
682
683
684 def time_to_string(
685     dt: datetime.datetime,
686     *,
687     include_seconds=True,
688     include_fractional=False,
689     include_timezone=False,
690     twelve_hour=True,
691 ) -> str:
692     """A nice way to convert a datetime into a time (only) string.
693     This ignores the date part of the datetime.
694
695     >>> d = string_to_datetime(
696     ...                        "2021/09/10 11:24:51AM-0700",
697     ...                       )[0]
698     >>> d
699     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
700
701     >>> time_to_string(d)
702     '11:24:51AM'
703
704     >>> time_to_string(d, include_seconds=False)
705     '11:24AM'
706
707     >>> time_to_string(d, include_seconds=False, include_timezone=True)
708     '11:24AM-0700'
709
710     """
711     fstring = ""
712     if twelve_hour:
713         fstring += "%l:%M"
714         if include_seconds:
715             fstring += ":%S"
716         fstring += "%p"
717     else:
718         fstring += "%H:%M"
719         if include_seconds:
720             fstring += ":%S"
721     if include_fractional:
722         fstring += ".%f"
723     if include_timezone:
724         fstring += "%z"
725     return dt.strftime(fstring).strip()
726
727
728 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
729     """Convert a delta in seconds into a timedelta."""
730     return datetime.timedelta(seconds=seconds)
731
732
733 MinuteOfDay = NewType("MinuteOfDay", int)
734
735
736 def minute_number(hour: int, minute: int) -> MinuteOfDay:
737     """
738     Convert hour:minute into minute number from start of day.
739
740     >>> minute_number(0, 0)
741     0
742
743     >>> minute_number(9, 15)
744     555
745
746     >>> minute_number(23, 59)
747     1439
748
749     """
750     return MinuteOfDay(hour * 60 + minute)
751
752
753 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
754     """
755     Convert a datetime into a minute number (of the day).  Note that
756     this ignores the date part of the datetime and only uses the time
757     part.
758
759     >>> d = string_to_datetime(
760     ...                        "2021/09/10 11:24:51AM-0700",
761     ...                       )[0]
762
763     >>> datetime_to_minute_number(d)
764     684
765
766     """
767     return minute_number(dt.hour, dt.minute)
768
769
770 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
771     """
772     Convert a datetime.time into a minute number.
773
774     >>> t = datetime.time(5, 15)
775     >>> time_to_minute_number(t)
776     315
777
778     """
779     return minute_number(t.hour, t.minute)
780
781
782 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
783     """
784     Convert minute number from start of day into hour:minute am/pm
785     string.
786
787     >>> minute_number_to_time_string(315)
788     ' 5:15a'
789
790     >>> minute_number_to_time_string(684)
791     '11:24a'
792
793     """
794     hour = minute_num // 60
795     minute = minute_num % 60
796     ampm = "a"
797     if hour > 12:
798         hour -= 12
799         ampm = "p"
800     if hour == 12:
801         ampm = "p"
802     if hour == 0:
803         hour = 12
804     return f"{hour:2}:{minute:02}{ampm}"
805
806
807 def parse_duration(duration: str) -> int:
808     """
809     Parse a duration in string form into a delta seconds.
810
811     >>> parse_duration('15 days, 2 hours')
812     1303200
813
814     >>> parse_duration('15d 2h')
815     1303200
816
817     >>> parse_duration('100s')
818     100
819
820     >>> parse_duration('3min 2sec')
821     182
822
823     """
824     if duration.isdigit():
825         return int(duration)
826     seconds = 0
827     m = re.search(r'(\d+) *d[ays]*', duration)
828     if m is not None:
829         seconds += int(m.group(1)) * 60 * 60 * 24
830     m = re.search(r'(\d+) *h[ours]*', duration)
831     if m is not None:
832         seconds += int(m.group(1)) * 60 * 60
833     m = re.search(r'(\d+) *m[inutes]*', duration)
834     if m is not None:
835         seconds += int(m.group(1)) * 60
836     m = re.search(r'(\d+) *s[econds]*', duration)
837     if m is not None:
838         seconds += int(m.group(1))
839     return seconds
840
841
842 def describe_duration(seconds: int, *, include_seconds=False) -> str:
843     """
844     Describe a duration represented as a count of seconds nicely.
845
846     >>> describe_duration(182)
847     '3 minutes'
848
849     >>> describe_duration(182, include_seconds=True)
850     '3 minutes, and 2 seconds'
851
852     >>> describe_duration(100, include_seconds=True)
853     '1 minute, and 40 seconds'
854
855     describe_duration(1303200)
856     '15 days, 2 hours'
857
858     """
859     days = divmod(seconds, constants.SECONDS_PER_DAY)
860     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
861     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
862
863     descr = ""
864     if days[0] > 1:
865         descr = f"{int(days[0])} days, "
866     elif days[0] == 1:
867         descr = "1 day, "
868
869     if hours[0] > 1:
870         descr = descr + f"{int(hours[0])} hours, "
871     elif hours[0] == 1:
872         descr = descr + "1 hour, "
873
874     if not include_seconds and len(descr) > 0:
875         descr = descr + "and "
876
877     if minutes[0] == 1:
878         descr = descr + "1 minute"
879     else:
880         descr = descr + f"{int(minutes[0])} minutes"
881
882     if include_seconds:
883         descr = descr + ', '
884         if len(descr) > 0:
885             descr = descr + 'and '
886         s = minutes[1]
887         if s == 1:
888             descr = descr + '1 second'
889         else:
890             descr = descr + f'{s} seconds'
891     return descr
892
893
894 def describe_timedelta(delta: datetime.timedelta) -> str:
895     """
896     Describe a duration represented by a timedelta object.
897
898     >>> d = datetime.timedelta(1, 600)
899     >>> describe_timedelta(d)
900     '1 day, and 10 minutes'
901
902     """
903     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
904
905
906 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
907     """
908     Describe a duration briefly.
909
910     >>> describe_duration_briefly(182)
911     '3m'
912
913     >>> describe_duration_briefly(182, include_seconds=True)
914     '3m 2s'
915
916     >>> describe_duration_briefly(100, include_seconds=True)
917     '1m 40s'
918
919     describe_duration_briefly(1303200)
920     '15d 2h'
921
922     """
923     days = divmod(seconds, constants.SECONDS_PER_DAY)
924     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
925     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
926
927     descr = ''
928     if days[0] > 0:
929         descr = f'{int(days[0])}d '
930     if hours[0] > 0:
931         descr = descr + f'{int(hours[0])}h '
932     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
933         descr = descr + f'{int(minutes[0])}m '
934     if minutes[1] > 0 and include_seconds:
935         descr = descr + f'{int(minutes[1])}s'
936     return descr.strip()
937
938
939 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
940     """
941     Describe a duration represented by a timedelta object.
942
943     >>> d = datetime.timedelta(1, 600)
944     >>> describe_timedelta_briefly(d)
945     '1d 10m'
946
947     """
948     return describe_duration_briefly(
949         int(delta.total_seconds())
950     )  # Note: drops milliseconds
951
952
953 if __name__ == '__main__':
954     import doctest
955
956     doctest.testmod()