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