Lots of changes.
[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 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
81 def n_timeunits_from_base(
82     count: int,
83     unit: TimeUnit,
84     base: datetime.datetime
85 ) -> datetime.datetime:
86     if count == 0:
87         return base
88
89     # N days from base
90     if unit == TimeUnit.DAYS:
91         timedelta = datetime.timedelta(days=count)
92         return base + timedelta
93
94     # N workdays from base
95     elif unit == TimeUnit.WORKDAYS:
96         if count < 0:
97             count = abs(count)
98             timedelta = datetime.timedelta(days=-1)
99         else:
100             timedelta = datetime.timedelta(days=1)
101         skips = holidays.US(years=base.year).keys()
102         while count > 0:
103             old_year = base.year
104             base += timedelta
105             if base.year != old_year:
106                 skips = holidays.US(years=base.year).keys()
107             if (
108                     base.weekday() < 5 and
109                     datetime.date(base.year,
110                                   base.month,
111                                   base.day) not in skips
112             ):
113                 count -= 1
114         return base
115
116     # N weeks from base
117     elif unit == TimeUnit.WEEKS:
118         timedelta = datetime.timedelta(weeks=count)
119         base = base + timedelta
120         return base
121
122     # N months from base
123     elif unit == TimeUnit.MONTHS:
124         month_term = count % 12
125         year_term = count // 12
126         new_month = base.month + month_term
127         if new_month > 12:
128             new_month %= 12
129             year_term += 1
130         new_year = base.year + year_term
131         return datetime.datetime(
132             new_year,
133             new_month,
134             base.day,
135             base.hour,
136             base.minute,
137             base.second,
138             base.microsecond,
139         )
140
141     # N years from base
142     elif unit == TimeUnit.YEARS:
143         new_year = base.year + count
144         return datetime.datetime(
145             new_year,
146             base.month,
147             base.day,
148             base.hour,
149             base.minute,
150             base.second,
151             base.microsecond,
152         )
153
154     # N weekdays from base (e.g. 4 wednesdays from today)
155     direction = 1 if count > 0 else -1
156     count = abs(count)
157     timedelta = datetime.timedelta(days=direction)
158     start = base
159     while True:
160         dow = base.weekday()
161         if dow == unit and start != base:
162             count -= 1
163             if count == 0:
164                 return base
165         base = base + timedelta
166
167
168 def get_format_string(
169         *,
170         date_time_separator=" ",
171         include_timezone=True,
172         include_dayname=False,
173         use_month_abbrevs=False,
174         include_seconds=True,
175         include_fractional=False,
176         twelve_hour=True,
177 ) -> str:
178     fstring = ""
179     if include_dayname:
180         fstring += "%a/"
181
182     if use_month_abbrevs:
183         fstring = f"%Y/%b/%d{date_time_separator}"
184     else:
185         fstring = f"%Y/%m/%d{date_time_separator}"
186     if twelve_hour:
187         fstring += "%I:%M"
188         if include_seconds:
189             fstring += ":%S"
190         fstring += "%p"
191     else:
192         fstring += "%H:%M"
193         if include_seconds:
194             fstring += ":%S"
195     if include_fractional:
196         fstring += ".%f"
197     if include_timezone:
198         fstring += "%z"
199     return fstring
200
201
202 def datetime_to_string(
203     dt: datetime.datetime,
204     *,
205     date_time_separator=" ",
206     include_timezone=True,
207     include_dayname=False,
208     use_month_abbrevs=False,
209     include_seconds=True,
210     include_fractional=False,
211     twelve_hour=True,
212 ) -> str:
213     """A nice way to convert a datetime into a string."""
214     fstring = get_format_string(
215         date_time_separator=date_time_separator,
216         include_timezone=include_timezone,
217         include_dayname=include_dayname,
218         include_seconds=include_seconds,
219         include_fractional=include_fractional,
220         twelve_hour=twelve_hour)
221     return dt.strftime(fstring).strip()
222
223
224 def string_to_datetime(
225         txt: str,
226         *,
227         date_time_separator=" ",
228         include_timezone=True,
229         include_dayname=False,
230         use_month_abbrevs=False,
231         include_seconds=True,
232         include_fractional=False,
233         twelve_hour=True,
234 ) -> Tuple[datetime.datetime, str]:
235     """A nice way to convert a string into a datetime.  Also consider
236     dateparse.dateparse_utils for a full parser.
237     """
238     fstring = get_format_string(
239         date_time_separator=date_time_separator,
240         include_timezone=include_timezone,
241         include_dayname=include_dayname,
242         include_seconds=include_seconds,
243         include_fractional=include_fractional,
244         twelve_hour=twelve_hour)
245     return (
246         datetime.datetime.strptime(txt, fstring),
247         fstring
248     )
249
250
251 def timestamp() -> str:
252     """Return a timestamp for now in Pacific timezone."""
253     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
254     return datetime_to_string(ts, include_timezone=True)
255
256
257 def time_to_string(
258     dt: datetime.datetime,
259     *,
260     include_seconds=True,
261     include_fractional=False,
262     include_timezone=False,
263     twelve_hour=True,
264 ) -> str:
265     """A nice way to convert a datetime into a time (only) string."""
266     fstring = ""
267     if twelve_hour:
268         fstring += "%l:%M"
269         if include_seconds:
270             fstring += ":%S"
271         fstring += "%p"
272     else:
273         fstring += "%H:%M"
274         if include_seconds:
275             fstring += ":%S"
276     if include_fractional:
277         fstring += ".%f"
278     if include_timezone:
279         fstring += "%z"
280     return dt.strftime(fstring).strip()
281
282
283 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
284     """Convert a delta in seconds into a timedelta."""
285     return datetime.timedelta(seconds=seconds)
286
287
288 MinuteOfDay = NewType("MinuteOfDay", int)
289
290
291 def minute_number(hour: int, minute: int) -> MinuteOfDay:
292     """Convert hour:minute into minute number from start of day."""
293     return MinuteOfDay(hour * 60 + minute)
294
295
296 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
297     """Convert a datetime into a minute number (of the day)"""
298     return minute_number(dt.hour, dt.minute)
299
300
301 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
302     """Convert minute number from start of day into hour:minute am/pm
303     string.
304     """
305     hour = minute_num // 60
306     minute = minute_num % 60
307     ampm = "a"
308     if hour > 12:
309         hour -= 12
310         ampm = "p"
311     if hour == 12:
312         ampm = "p"
313     if hour == 0:
314         hour = 12
315     return f"{hour:2}:{minute:02}{ampm}"
316
317
318 def parse_duration(duration: str) -> int:
319     """Parse a duration in string form."""
320     seconds = 0
321     m = re.search(r'(\d+) *d[ays]*', duration)
322     if m is not None:
323         seconds += int(m.group(1)) * 60 * 60 * 24
324     m = re.search(r'(\d+) *h[ours]*', duration)
325     if m is not None:
326         seconds += int(m.group(1)) * 60 * 60
327     m = re.search(r'(\d+) *m[inutes]*', duration)
328     if m is not None:
329         seconds += int(m.group(1)) * 60
330     m = re.search(r'(\d+) *s[econds]*', duration)
331     if m is not None:
332         seconds += int(m.group(1))
333     return seconds
334
335
336 def describe_duration(age: int) -> str:
337     """Describe a duration."""
338     days = divmod(age, constants.SECONDS_PER_DAY)
339     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
340     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
341
342     descr = ""
343     if days[0] > 1:
344         descr = f"{int(days[0])} days, "
345     elif days[0] == 1:
346         descr = "1 day, "
347     if hours[0] > 1:
348         descr = descr + f"{int(hours[0])} hours, "
349     elif hours[0] == 1:
350         descr = descr + "1 hour, "
351     if len(descr) > 0:
352         descr = descr + "and "
353     if minutes[0] == 1:
354         descr = descr + "1 minute"
355     else:
356         descr = descr + f"{int(minutes[0])} minutes"
357     return descr
358
359
360 def describe_duration_briefly(age: int) -> str:
361     """Describe a duration briefly."""
362     days = divmod(age, constants.SECONDS_PER_DAY)
363     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
364     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
365
366     descr = ""
367     if days[0] > 0:
368         descr = f"{int(days[0])}d "
369     if hours[0] > 0:
370         descr = descr + f"{int(hours[0])}h "
371     if minutes[0] > 0 or len(descr) == 0:
372         descr = descr + f"{int(minutes[0])}m"
373     return descr.strip()