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