Move cache location. Also, add doctests for exec_utils.
[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, 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 (
31         dt.tzinfo is not None and
32         dt.tzinfo.utcoffset(dt) is not None
33     )
34
35
36 def is_timezone_naive(dt: datetime.datetime) -> bool:
37     return not is_timezone_aware(dt)
38
39
40 def replace_timezone(dt: datetime.datetime,
41                      tz: datetime.tzinfo) -> datetime.datetime:
42     """
43     Replaces the timezone on a datetime object directly (leaving
44     the year, month, day, hour, minute, second, micro, etc... alone).
45     Note: this changes the instant to which this dt refers.
46
47     >>> from pytz import UTC
48     >>> d = now_pacific()
49     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
50     'P'
51     >>> h = d.hour
52     >>> o = replace_timezone(d, UTC)
53     >>> o.tzinfo.tzname(o)
54     'UTC'
55     >>> o.hour == h
56     True
57
58     """
59     return datetime.datetime(
60         dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond,
61         tzinfo=tz
62     )
63
64
65 def translate_timezone(dt: datetime.datetime,
66                        tz: datetime.tzinfo) -> datetime.datetime:
67     """
68     Translates dt into a different timezone by adjusting the year, month,
69     day, hour, minute, second, micro, etc... appropriately.  The returned
70     dt is the same instant in another timezone.
71
72     >>> from pytz import UTC
73     >>> d = now_pacific()
74     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
75     'P'
76     >>> h = d.hour
77     >>> o = translate_timezone(d, UTC)
78     >>> o.tzinfo.tzname(o)
79     'UTC'
80     >>> o.hour == h
81     False
82
83     """
84     return dt.replace(tzinfo=None).astimezone(tz=tz)
85
86
87 def now() -> datetime.datetime:
88     """
89     What time is it?  Result returned in UTC
90     """
91     return datetime.datetime.now()
92
93
94 def now_pacific() -> datetime.datetime:
95     """
96     What time is it?  Result in US/Pacific time (PST/PDT)
97     """
98     return datetime.datetime.now(pytz.timezone("US/Pacific"))
99
100
101 def date_to_datetime(date: datetime.date) -> datetime.datetime:
102     """
103     Given a date, return a datetime with hour/min/sec zero (midnight)
104
105     >>> import datetime
106     >>> date_to_datetime(datetime.date(2021, 12, 25))
107     datetime.datetime(2021, 12, 25, 0, 0)
108
109     """
110     return datetime.datetime(
111         date.year,
112         date.month,
113         date.day,
114         0, 0, 0, 0
115     )
116
117
118 def date_and_time_to_datetime(date: datetime.date,
119                               time: datetime.time) -> datetime.datetime:
120     """
121     Given a date and time, merge them and return a datetime.
122
123     >>> import datetime
124     >>> d = datetime.date(2021, 12, 25)
125     >>> t = datetime.time(12, 30, 0, 0)
126     >>> date_and_time_to_datetime(d, t)
127     datetime.datetime(2021, 12, 25, 12, 30)
128
129     """
130     return datetime.datetime(
131         date.year,
132         date.month,
133         date.day,
134         time.hour,
135         time.minute,
136         time.second,
137         time.microsecond,
138     )
139
140
141 def datetime_to_date_and_time(
142         dt: datetime.datetime
143 ) -> Tuple[datetime.date, datetime.time]:
144     """Return the component date and time objects of a datetime.
145
146     >>> import datetime
147     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
148     >>> (d, t) = datetime_to_date_and_time(dt)
149     >>> d
150     datetime.date(2021, 12, 25)
151     >>> t
152     datetime.time(12, 30)
153
154     """
155     return (dt.date(), dt.timetz())
156
157
158 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
159     """Return the date part of a datetime.
160
161     >>> import datetime
162     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
163     >>> datetime_to_date(dt)
164     datetime.date(2021, 12, 25)
165
166     """
167     return datetime_to_date_and_time(dt)[0]
168
169
170 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
171     """Return the time part of a datetime.
172
173     >>> import datetime
174     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
175     >>> datetime_to_time(dt)
176     datetime.time(12, 30)
177
178     """
179     return datetime_to_date_and_time(dt)[1]
180
181
182 class TimeUnit(enum.Enum):
183     """An enum to represent units with which we can compute deltas."""
184     MONDAYS = 0
185     TUESDAYS = 1
186     WEDNESDAYS = 2
187     THURSDAYS = 3
188     FRIDAYS = 4
189     SATURDAYS = 5
190     SUNDAYS = 6
191     SECONDS = 7
192     MINUTES = 8
193     HOURS = 9
194     DAYS = 10
195     WORKDAYS = 11
196     WEEKS = 12
197     MONTHS = 13
198     YEARS = 14
199
200     @classmethod
201     def is_valid(cls, value: Any):
202         if type(value) is int:
203             return value in cls._value2member_map_
204         elif type(value) is TimeUnit:
205             return value.value in cls._value2member_map_
206         elif type(value) is str:
207             return value in cls._member_names_
208         else:
209             print(type(value))
210             return False
211
212
213 def n_timeunits_from_base(
214     count: int,
215     unit: TimeUnit,
216     base: datetime.datetime
217 ) -> datetime.datetime:
218     """Return a datetime that is N units before/after a base datetime.
219     e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
220     years before base datetime, 13 minutes after base datetime, etc...
221     Note: to indicate before/after the base date, use a positive or
222     negative count.
223
224     >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
225
226     The next (1) Monday from the base datetime:
227     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
228     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
229
230     Ten (10) years after the base datetime:
231     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
232     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
233
234     Fifty (50) working days (M..F, not counting holidays) after base datetime:
235     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
236     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
237
238     Fifty (50) days (including weekends and holidays) after base datetime:
239     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
240     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
241
242     Fifty (50) months before (note negative count) base datetime:
243     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
244     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
245
246     Fifty (50) hours after base datetime:
247     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
248     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
249
250     Fifty (50) minutes before base datetime:
251     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
252     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
253
254     Fifty (50) seconds from base datetime:
255     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
256     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
257
258     """
259     assert TimeUnit.is_valid(unit)
260     if count == 0:
261         return base
262
263     # N days from base
264     if unit == TimeUnit.DAYS:
265         timedelta = datetime.timedelta(days=count)
266         return base + timedelta
267
268     # N hours from base
269     elif unit == TimeUnit.HOURS:
270         timedelta = datetime.timedelta(hours=count)
271         return base + timedelta
272
273     # N minutes from base
274     elif unit == TimeUnit.MINUTES:
275         timedelta = datetime.timedelta(minutes=count)
276         return base + timedelta
277
278     # N seconds from base
279     elif unit == TimeUnit.SECONDS:
280         timedelta = datetime.timedelta(seconds=count)
281         return base + timedelta
282
283     # N workdays from base
284     elif unit == TimeUnit.WORKDAYS:
285         if count < 0:
286             count = abs(count)
287             timedelta = datetime.timedelta(days=-1)
288         else:
289             timedelta = datetime.timedelta(days=1)
290         skips = holidays.US(years=base.year).keys()
291         while count > 0:
292             old_year = base.year
293             base += timedelta
294             if base.year != old_year:
295                 skips = holidays.US(years=base.year).keys()
296             if (
297                     base.weekday() < 5 and
298                     datetime.date(base.year,
299                                   base.month,
300                                   base.day) not in skips
301             ):
302                 count -= 1
303         return base
304
305     # N weeks from base
306     elif unit == TimeUnit.WEEKS:
307         timedelta = datetime.timedelta(weeks=count)
308         base = base + timedelta
309         return base
310
311     # N months from base
312     elif unit == TimeUnit.MONTHS:
313         month_term = count % 12
314         year_term = count // 12
315         new_month = base.month + month_term
316         if new_month > 12:
317             new_month %= 12
318             year_term += 1
319         new_year = base.year + year_term
320         return datetime.datetime(
321             new_year,
322             new_month,
323             base.day,
324             base.hour,
325             base.minute,
326             base.second,
327             base.microsecond,
328             base.tzinfo,
329         )
330
331     # N years from base
332     elif unit == TimeUnit.YEARS:
333         new_year = base.year + count
334         return datetime.datetime(
335             new_year,
336             base.month,
337             base.day,
338             base.hour,
339             base.minute,
340             base.second,
341             base.microsecond,
342             base.tzinfo,
343         )
344
345     if unit not in set([TimeUnit.MONDAYS,
346                         TimeUnit.TUESDAYS,
347                         TimeUnit.WEDNESDAYS,
348                         TimeUnit.THURSDAYS,
349                         TimeUnit.FRIDAYS,
350                         TimeUnit.SATURDAYS,
351                         TimeUnit.SUNDAYS]):
352         raise ValueError(unit)
353
354     # N weekdays from base (e.g. 4 wednesdays from today)
355     direction = 1 if count > 0 else -1
356     count = abs(count)
357     timedelta = datetime.timedelta(days=direction)
358     start = base
359     while True:
360         dow = base.weekday()
361         if dow == unit.value and start != base:
362             count -= 1
363             if count == 0:
364                 return base
365         base = base + timedelta
366
367
368 def get_format_string(
369         *,
370         date_time_separator=" ",
371         include_timezone=True,
372         include_dayname=False,
373         use_month_abbrevs=False,
374         include_seconds=True,
375         include_fractional=False,
376         twelve_hour=True,
377 ) -> str:
378     """
379     Helper to return a format string without looking up the documentation
380     for strftime.
381
382     >>> get_format_string()
383     '%Y/%m/%d %I:%M:%S%p%z'
384
385     >>> get_format_string(date_time_separator='@')
386     '%Y/%m/%d@%I:%M:%S%p%z'
387
388     >>> get_format_string(include_dayname=True)
389     '%a/%Y/%m/%d %I:%M:%S%p%z'
390
391     >>> get_format_string(include_dayname=True, twelve_hour=False)
392     '%a/%Y/%m/%d %H:%M:%S%z'
393
394     """
395     fstring = ""
396     if include_dayname:
397         fstring += "%a/"
398
399     if use_month_abbrevs:
400         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
401     else:
402         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
403     if twelve_hour:
404         fstring += "%I:%M"
405         if include_seconds:
406             fstring += ":%S"
407         fstring += "%p"
408     else:
409         fstring += "%H:%M"
410         if include_seconds:
411             fstring += ":%S"
412     if include_fractional:
413         fstring += ".%f"
414     if include_timezone:
415         fstring += "%z"
416     return fstring
417
418
419 def datetime_to_string(
420     dt: datetime.datetime,
421     *,
422     date_time_separator=" ",
423     include_timezone=True,
424     include_dayname=False,
425     use_month_abbrevs=False,
426     include_seconds=True,
427     include_fractional=False,
428     twelve_hour=True,
429 ) -> str:
430     """
431     A nice way to convert a datetime into a string; arguably better than
432     just printing it and relying on it __repr__().
433
434     >>> d = string_to_datetime(
435     ...                        "2021/09/10 11:24:51AM-0700",
436     ...                       )[0]
437     >>> d
438     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
439     >>> datetime_to_string(d)
440     '2021/09/10 11:24:51AM-0700'
441     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
442     'Fri/2021/09/10 11:24AM-0700'
443
444     """
445     fstring = get_format_string(
446         date_time_separator=date_time_separator,
447         include_timezone=include_timezone,
448         include_dayname=include_dayname,
449         include_seconds=include_seconds,
450         include_fractional=include_fractional,
451         twelve_hour=twelve_hour)
452     return dt.strftime(fstring).strip()
453
454
455 def string_to_datetime(
456         txt: str,
457         *,
458         date_time_separator=" ",
459         include_timezone=True,
460         include_dayname=False,
461         use_month_abbrevs=False,
462         include_seconds=True,
463         include_fractional=False,
464         twelve_hour=True,
465 ) -> Tuple[datetime.datetime, str]:
466     """A nice way to convert a string into a datetime.  Returns both the
467     datetime and the format string used to parse it.  Also consider
468     dateparse.dateparse_utils for a full parser alternative.
469
470     >>> d = string_to_datetime(
471     ...                        "2021/09/10 11:24:51AM-0700",
472     ...                       )
473     >>> d
474     (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')
475
476     """
477     fstring = get_format_string(
478         date_time_separator=date_time_separator,
479         include_timezone=include_timezone,
480         include_dayname=include_dayname,
481         include_seconds=include_seconds,
482         include_fractional=include_fractional,
483         twelve_hour=twelve_hour)
484     return (
485         datetime.datetime.strptime(txt, fstring),
486         fstring
487     )
488
489
490 def timestamp() -> str:
491     """Return a timestamp for right now in Pacific timezone."""
492     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
493     return datetime_to_string(ts, include_timezone=True)
494
495
496 def time_to_string(
497     dt: datetime.datetime,
498     *,
499     include_seconds=True,
500     include_fractional=False,
501     include_timezone=False,
502     twelve_hour=True,
503 ) -> str:
504     """A nice way to convert a datetime into a time (only) string.
505     This ignores the date part of the datetime.
506
507     >>> d = string_to_datetime(
508     ...                        "2021/09/10 11:24:51AM-0700",
509     ...                       )[0]
510     >>> d
511     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
512
513     >>> time_to_string(d)
514     '11:24:51AM'
515
516     >>> time_to_string(d, include_seconds=False)
517     '11:24AM'
518
519     >>> time_to_string(d, include_seconds=False, include_timezone=True)
520     '11:24AM-0700'
521
522     """
523     fstring = ""
524     if twelve_hour:
525         fstring += "%l:%M"
526         if include_seconds:
527             fstring += ":%S"
528         fstring += "%p"
529     else:
530         fstring += "%H:%M"
531         if include_seconds:
532             fstring += ":%S"
533     if include_fractional:
534         fstring += ".%f"
535     if include_timezone:
536         fstring += "%z"
537     return dt.strftime(fstring).strip()
538
539
540 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
541     """Convert a delta in seconds into a timedelta."""
542     return datetime.timedelta(seconds=seconds)
543
544
545 MinuteOfDay = NewType("MinuteOfDay", int)
546
547
548 def minute_number(hour: int, minute: int) -> MinuteOfDay:
549     """
550     Convert hour:minute into minute number from start of day.
551
552     >>> minute_number(0, 0)
553     0
554
555     >>> minute_number(9, 15)
556     555
557
558     >>> minute_number(23, 59)
559     1439
560
561     """
562     return MinuteOfDay(hour * 60 + minute)
563
564
565 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
566     """
567     Convert a datetime into a minute number (of the day).  Note that
568     this ignores the date part of the datetime and only uses the time
569     part.
570
571     >>> d = string_to_datetime(
572     ...                        "2021/09/10 11:24:51AM-0700",
573     ...                       )[0]
574
575     >>> datetime_to_minute_number(d)
576     684
577
578     """
579     return minute_number(dt.hour, dt.minute)
580
581
582 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
583     """
584     Convert a datetime.time into a minute number.
585
586     >>> t = datetime.time(5, 15)
587     >>> time_to_minute_number(t)
588     315
589
590     """
591     return minute_number(t.hour, t.minute)
592
593
594 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
595     """
596     Convert minute number from start of day into hour:minute am/pm
597     string.
598
599     >>> minute_number_to_time_string(315)
600     ' 5:15a'
601
602     >>> minute_number_to_time_string(684)
603     '11:24a'
604
605     """
606     hour = minute_num // 60
607     minute = minute_num % 60
608     ampm = "a"
609     if hour > 12:
610         hour -= 12
611         ampm = "p"
612     if hour == 12:
613         ampm = "p"
614     if hour == 0:
615         hour = 12
616     return f"{hour:2}:{minute:02}{ampm}"
617
618
619 def parse_duration(duration: str) -> int:
620     """
621     Parse a duration in string form into a delta seconds.
622
623     >>> parse_duration('15 days, 2 hours')
624     1303200
625
626     >>> parse_duration('15d 2h')
627     1303200
628
629     >>> parse_duration('100s')
630     100
631
632     >>> parse_duration('3min 2sec')
633     182
634
635     """
636     if duration.isdigit():
637         return int(duration)
638     seconds = 0
639     m = re.search(r'(\d+) *d[ays]*', duration)
640     if m is not None:
641         seconds += int(m.group(1)) * 60 * 60 * 24
642     m = re.search(r'(\d+) *h[ours]*', duration)
643     if m is not None:
644         seconds += int(m.group(1)) * 60 * 60
645     m = re.search(r'(\d+) *m[inutes]*', duration)
646     if m is not None:
647         seconds += int(m.group(1)) * 60
648     m = re.search(r'(\d+) *s[econds]*', duration)
649     if m is not None:
650         seconds += int(m.group(1))
651     return seconds
652
653
654 def describe_duration(seconds: int, *, include_seconds = False) -> str:
655     """
656     Describe a duration represented as a count of seconds nicely.
657
658     >>> describe_duration(182)
659     '3 minutes'
660
661     >>> describe_duration(182, include_seconds=True)
662     '3 minutes, and 2 seconds'
663
664     >>> describe_duration(100, include_seconds=True)
665     '1 minute, and 40 seconds'
666
667     describe_duration(1303200)
668     '15 days, 2 hours'
669
670     """
671     days = divmod(seconds, constants.SECONDS_PER_DAY)
672     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
673     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
674
675     descr = ""
676     if days[0] > 1:
677         descr = f"{int(days[0])} days, "
678     elif days[0] == 1:
679         descr = "1 day, "
680
681     if hours[0] > 1:
682         descr = descr + f"{int(hours[0])} hours, "
683     elif hours[0] == 1:
684         descr = descr + "1 hour, "
685
686     if not include_seconds and len(descr) > 0:
687         descr = descr + "and "
688
689     if minutes[0] == 1:
690         descr = descr + "1 minute"
691     else:
692         descr = descr + f"{int(minutes[0])} minutes"
693
694     if include_seconds:
695         descr = descr + ', '
696         if len(descr) > 0:
697             descr = descr + 'and '
698         s = minutes[1]
699         if s == 1:
700             descr = descr + '1 second'
701         else:
702             descr = descr + f'{s} seconds'
703     return descr
704
705
706 def describe_timedelta(delta: datetime.timedelta) -> str:
707     """
708     Describe a duration represented by a timedelta object.
709
710     >>> d = datetime.timedelta(1, 600)
711     >>> describe_timedelta(d)
712     '1 day, and 10 minutes'
713
714     """
715     return describe_duration(delta.total_seconds())
716
717
718 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
719     """
720     Describe a duration briefly.
721
722     >>> describe_duration_briefly(182)
723     '3m'
724
725     >>> describe_duration_briefly(182, include_seconds=True)
726     '3m 2s'
727
728     >>> describe_duration_briefly(100, include_seconds=True)
729     '1m 40s'
730
731     describe_duration_briefly(1303200)
732     '15d 2h'
733
734     """
735     days = divmod(seconds, constants.SECONDS_PER_DAY)
736     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
737     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
738
739     descr = ''
740     if days[0] > 0:
741         descr = f'{int(days[0])}d '
742     if hours[0] > 0:
743         descr = descr + f'{int(hours[0])}h '
744     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
745         descr = descr + f'{int(minutes[0])}m '
746     if minutes[1] > 0 and include_seconds:
747         descr = descr + f'{int(minutes[1])}s'
748     return descr.strip()
749
750
751 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
752     """
753     Describe a duration represented by a timedelta object.
754
755     >>> d = datetime.timedelta(1, 600)
756     >>> describe_timedelta_briefly(d)
757     '1d 10m'
758
759     """
760     return describe_duration_briefly(delta.total_seconds())
761
762
763 if __name__ == '__main__':
764     import doctest
765     doctest.testmod()