ACL uses enums, some more tests, other stuff.
[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     return dt.replace(tzinfo=None).astimezone(tz=tz)
22
23
24 def now() -> datetime.datetime:
25     return datetime.datetime.now()
26
27
28 def now_pst() -> datetime.datetime:
29     return replace_timezone(now(), pytz.timezone("US/Pacific"))
30
31
32 def date_to_datetime(date: datetime.date) -> datetime.datetime:
33     return datetime.datetime(
34         date.year,
35         date.month,
36         date.day,
37         0, 0, 0, 0
38     )
39
40
41 def date_and_time_to_datetime(date: datetime.date,
42                               time: datetime.time) -> datetime.datetime:
43     return datetime.datetime(
44         date.year,
45         date.month,
46         date.day,
47         time.hour,
48         time.minute,
49         time.second,
50         time.millisecond
51     )
52
53
54 def datetime_to_date(date: datetime.datetime) -> datetime.date:
55     return datetime.date(
56         date.year,
57         date.month,
58         date.day
59     )
60
61
62 # An enum to represent units with which we can compute deltas.
63 class TimeUnit(enum.Enum):
64     MONDAYS = 0
65     TUESDAYS = 1
66     WEDNESDAYS = 2
67     THURSDAYS = 3
68     FRIDAYS = 4
69     SATURDAYS = 5
70     SUNDAYS = 6
71     SECONDS = 7
72     MINUTES = 8
73     HOURS = 9
74     DAYS = 10
75     WORKDAYS = 11
76     WEEKS = 12
77     MONTHS = 13
78     YEARS = 14
79
80     @classmethod
81     def is_valid(cls, value: Any):
82         if type(value) is int:
83             print("int")
84             return value in cls._value2member_map_
85         elif type(value) is TimeUnit:
86             print("TimeUnit")
87             return value.value in cls._value2member_map_
88         elif type(value) is str:
89             print("str")
90             return value in cls._member_names_
91         else:
92             print(type(value))
93             return False
94
95
96 def n_timeunits_from_base(
97     count: int,
98     unit: TimeUnit,
99     base: datetime.datetime
100 ) -> datetime.datetime:
101     assert TimeUnit.is_valid(unit)
102     if count == 0:
103         return base
104
105     # N days from base
106     if unit == TimeUnit.DAYS:
107         timedelta = datetime.timedelta(days=count)
108         return base + timedelta
109
110     # N workdays from base
111     elif unit == TimeUnit.WORKDAYS:
112         if count < 0:
113             count = abs(count)
114             timedelta = datetime.timedelta(days=-1)
115         else:
116             timedelta = datetime.timedelta(days=1)
117         skips = holidays.US(years=base.year).keys()
118         while count > 0:
119             old_year = base.year
120             base += timedelta
121             if base.year != old_year:
122                 skips = holidays.US(years=base.year).keys()
123             if (
124                     base.weekday() < 5 and
125                     datetime.date(base.year,
126                                   base.month,
127                                   base.day) not in skips
128             ):
129                 count -= 1
130         return base
131
132     # N weeks from base
133     elif unit == TimeUnit.WEEKS:
134         timedelta = datetime.timedelta(weeks=count)
135         base = base + timedelta
136         return base
137
138     # N months from base
139     elif unit == TimeUnit.MONTHS:
140         month_term = count % 12
141         year_term = count // 12
142         new_month = base.month + month_term
143         if new_month > 12:
144             new_month %= 12
145             year_term += 1
146         new_year = base.year + year_term
147         return datetime.datetime(
148             new_year,
149             new_month,
150             base.day,
151             base.hour,
152             base.minute,
153             base.second,
154             base.microsecond,
155         )
156
157     # N years from base
158     elif unit == TimeUnit.YEARS:
159         new_year = base.year + count
160         return datetime.datetime(
161             new_year,
162             base.month,
163             base.day,
164             base.hour,
165             base.minute,
166             base.second,
167             base.microsecond,
168         )
169
170     # N weekdays from base (e.g. 4 wednesdays from today)
171     direction = 1 if count > 0 else -1
172     count = abs(count)
173     timedelta = datetime.timedelta(days=direction)
174     start = base
175     while True:
176         dow = base.weekday()
177         if dow == unit and start != base:
178             count -= 1
179             if count == 0:
180                 return base
181         base = base + timedelta
182
183
184 def get_format_string(
185         *,
186         date_time_separator=" ",
187         include_timezone=True,
188         include_dayname=False,
189         use_month_abbrevs=False,
190         include_seconds=True,
191         include_fractional=False,
192         twelve_hour=True,
193 ) -> str:
194     fstring = ""
195     if include_dayname:
196         fstring += "%a/"
197
198     if use_month_abbrevs:
199         fstring = f"%Y/%b/%d{date_time_separator}"
200     else:
201         fstring = f"%Y/%m/%d{date_time_separator}"
202     if twelve_hour:
203         fstring += "%I:%M"
204         if include_seconds:
205             fstring += ":%S"
206         fstring += "%p"
207     else:
208         fstring += "%H:%M"
209         if include_seconds:
210             fstring += ":%S"
211     if include_fractional:
212         fstring += ".%f"
213     if include_timezone:
214         fstring += "%z"
215     return fstring
216
217
218 def datetime_to_string(
219     dt: datetime.datetime,
220     *,
221     date_time_separator=" ",
222     include_timezone=True,
223     include_dayname=False,
224     use_month_abbrevs=False,
225     include_seconds=True,
226     include_fractional=False,
227     twelve_hour=True,
228 ) -> str:
229     """A nice way to convert a datetime into a string."""
230     fstring = get_format_string(
231         date_time_separator=date_time_separator,
232         include_timezone=include_timezone,
233         include_dayname=include_dayname,
234         include_seconds=include_seconds,
235         include_fractional=include_fractional,
236         twelve_hour=twelve_hour)
237     return dt.strftime(fstring).strip()
238
239
240 def string_to_datetime(
241         txt: str,
242         *,
243         date_time_separator=" ",
244         include_timezone=True,
245         include_dayname=False,
246         use_month_abbrevs=False,
247         include_seconds=True,
248         include_fractional=False,
249         twelve_hour=True,
250 ) -> Tuple[datetime.datetime, str]:
251     """A nice way to convert a string into a datetime.  Also consider
252     dateparse.dateparse_utils for a full parser.
253     """
254     fstring = get_format_string(
255         date_time_separator=date_time_separator,
256         include_timezone=include_timezone,
257         include_dayname=include_dayname,
258         include_seconds=include_seconds,
259         include_fractional=include_fractional,
260         twelve_hour=twelve_hour)
261     return (
262         datetime.datetime.strptime(txt, fstring),
263         fstring
264     )
265
266
267 def timestamp() -> str:
268     """Return a timestamp for now in Pacific timezone."""
269     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
270     return datetime_to_string(ts, include_timezone=True)
271
272
273 def time_to_string(
274     dt: datetime.datetime,
275     *,
276     include_seconds=True,
277     include_fractional=False,
278     include_timezone=False,
279     twelve_hour=True,
280 ) -> str:
281     """A nice way to convert a datetime into a time (only) string."""
282     fstring = ""
283     if twelve_hour:
284         fstring += "%l:%M"
285         if include_seconds:
286             fstring += ":%S"
287         fstring += "%p"
288     else:
289         fstring += "%H:%M"
290         if include_seconds:
291             fstring += ":%S"
292     if include_fractional:
293         fstring += ".%f"
294     if include_timezone:
295         fstring += "%z"
296     return dt.strftime(fstring).strip()
297
298
299 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
300     """Convert a delta in seconds into a timedelta."""
301     return datetime.timedelta(seconds=seconds)
302
303
304 MinuteOfDay = NewType("MinuteOfDay", int)
305
306
307 def minute_number(hour: int, minute: int) -> MinuteOfDay:
308     """Convert hour:minute into minute number from start of day."""
309     return MinuteOfDay(hour * 60 + minute)
310
311
312 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
313     """Convert a datetime into a minute number (of the day)"""
314     return minute_number(dt.hour, dt.minute)
315
316
317 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
318     """Convert minute number from start of day into hour:minute am/pm
319     string.
320     """
321     hour = minute_num // 60
322     minute = minute_num % 60
323     ampm = "a"
324     if hour > 12:
325         hour -= 12
326         ampm = "p"
327     if hour == 12:
328         ampm = "p"
329     if hour == 0:
330         hour = 12
331     return f"{hour:2}:{minute:02}{ampm}"
332
333
334 def parse_duration(duration: str) -> int:
335     """Parse a duration in string form."""
336     seconds = 0
337     m = re.search(r'(\d+) *d[ays]*', duration)
338     if m is not None:
339         seconds += int(m.group(1)) * 60 * 60 * 24
340     m = re.search(r'(\d+) *h[ours]*', duration)
341     if m is not None:
342         seconds += int(m.group(1)) * 60 * 60
343     m = re.search(r'(\d+) *m[inutes]*', duration)
344     if m is not None:
345         seconds += int(m.group(1)) * 60
346     m = re.search(r'(\d+) *s[econds]*', duration)
347     if m is not None:
348         seconds += int(m.group(1))
349     return seconds
350
351
352 def describe_duration(age: int) -> str:
353     """Describe a duration."""
354     days = divmod(age, constants.SECONDS_PER_DAY)
355     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
356     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
357
358     descr = ""
359     if days[0] > 1:
360         descr = f"{int(days[0])} days, "
361     elif days[0] == 1:
362         descr = "1 day, "
363     if hours[0] > 1:
364         descr = descr + f"{int(hours[0])} hours, "
365     elif hours[0] == 1:
366         descr = descr + "1 hour, "
367     if len(descr) > 0:
368         descr = descr + "and "
369     if minutes[0] == 1:
370         descr = descr + "1 minute"
371     else:
372         descr = descr + f"{int(minutes[0])} minutes"
373     return descr
374
375
376 def describe_duration_briefly(age: int) -> str:
377     """Describe a duration briefly."""
378     days = divmod(age, constants.SECONDS_PER_DAY)
379     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
380     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
381
382     descr = ""
383     if days[0] > 0:
384         descr = f"{int(days[0])}d "
385     if hours[0] > 0:
386         descr = descr + f"{int(hours[0])}h "
387     if minutes[0] > 0 or len(descr) == 0:
388         descr = descr + f"{int(minutes[0])}m"
389     return descr.strip()