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