Initial revision
[python_utils.git] / dateparse / dateparse_utils.py
1 #!/usr/bin/env python3
2
3 import antlr4  # type: ignore
4 import datetime
5 import dateutil.easter
6 import holidays  # type: ignore
7 import re
8 import sys
9 from typing import Any, Dict, Optional
10
11 from dateparse.dateparse_utilsLexer import dateparse_utilsLexer  # type: ignore
12 from dateparse.dateparse_utilsListener import dateparse_utilsListener  # type: ignore
13 from dateparse.dateparse_utilsParser import dateparse_utilsParser  # type: ignore
14
15
16 class ParseException(Exception):
17     def __init__(self, message: str) -> None:
18         self.message = message
19
20
21 class DateParser(dateparse_utilsListener):
22     PARSE_TYPE_SINGLE_DATE_EXPR = 1
23     PARSE_TYPE_BASE_AND_OFFSET_EXPR = 2
24     CONSTANT_DAYS = 7
25     CONSTANT_WEEKS = 8
26     CONSTANT_MONTHS = 9
27     CONSTANT_YEARS = 10
28
29     def __init__(self):
30         self.month_name_to_number = {
31             "jan": 1,
32             "feb": 2,
33             "mar": 3,
34             "apr": 4,
35             "may": 5,
36             "jun": 6,
37             "jul": 7,
38             "aug": 8,
39             "sep": 9,
40             "oct": 10,
41             "nov": 11,
42             "dec": 12,
43         }
44         self.day_name_to_number = {
45             "mon": 0,
46             "tue": 1,
47             "wed": 2,
48             "thu": 3,
49             "fri": 4,
50             "sat": 5,
51             "sun": 6,
52         }
53         self.delta_unit_to_constant = {
54             "day": DateParser.CONSTANT_DAYS,
55             "wee": DateParser.CONSTANT_WEEKS,
56         }
57         self.date: Optional[datetime.date] = None
58
59     def parse_date_string(self, date_string: str) -> Optional[datetime.date]:
60         input_stream = antlr4.InputStream(date_string)
61         lexer = dateparse_utilsLexer(input_stream)
62         stream = antlr4.CommonTokenStream(lexer)
63         parser = dateparse_utilsParser(stream)
64         tree = parser.parse()
65         walker = antlr4.ParseTreeWalker()
66         walker.walk(self, tree)
67         return self.get_date()
68
69     def get_date(self) -> Optional[datetime.date]:
70         return self.date
71
72     def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
73         self.date = None
74         self.context: Dict[str, Any] = {}
75         if ctx.singleDateExpr() is not None:
76             self.main_type = DateParser.PARSE_TYPE_SINGLE_DATE_EXPR
77         elif ctx.baseAndOffsetDateExpr() is not None:
78             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
79
80     @staticmethod
81     def normalize_special_day_name(name: str) -> str:
82         name = name.lower()
83         name = name.replace("'", "")
84         name = name.replace("xmas", "christmas")
85         name = name.replace("mlk", "martin luther king")
86         name = name.replace(" ", "")
87         eve = "eve" if name[-3:] == "eve" else ""
88         name = name[:5] + eve
89         name = name.replace("washi", "presi")
90         return name
91
92     def parse_special(self, name: str) -> Optional[datetime.date]:
93         today = datetime.date.today()
94         year = self.context.get("year", today.year)
95         name = DateParser.normalize_special_day_name(self.context["special"])
96         if name == "today":
97             return today
98         if name == "easte":
99             return dateutil.easter.easter(year=year)
100         for holiday_date, holiday_name in sorted(
101             holidays.US(years=year).items()
102         ):
103             if "Observed" not in holiday_name:
104                 holiday_name = DateParser.normalize_special_day_name(
105                     holiday_name
106                 )
107                 if name == holiday_name:
108                     return holiday_date
109         if name == "chriseve":
110             return datetime.date(year=year, month=12, day=24)
111         elif name == "newyeeve":
112             return datetime.date(year=year, month=12, day=31)
113         return None
114
115     def parse_normal(self) -> datetime.date:
116         if "month" not in self.context:
117             raise ParseException("Missing month")
118         if "day" not in self.context:
119             raise ParseException("Missing day")
120         if "year" not in self.context:
121             today = datetime.date.today()
122             self.context["year"] = today.year
123         return datetime.date(
124             year=int(self.context["year"]),
125             month=int(self.context["month"]),
126             day=int(self.context["day"]),
127         )
128
129     def exitDateExpr(self, ctx: dateparse_utilsParser.DateExprContext) -> None:
130         """When we leave the date expression, populate self.date."""
131         if "special" in self.context:
132             self.date = self.parse_special(self.context["special"])
133         else:
134             self.date = self.parse_normal()
135         assert self.date is not None
136
137         # For a single date, just return the date we pulled out.
138         if self.main_type == DateParser.PARSE_TYPE_SINGLE_DATE_EXPR:
139             return
140
141         # Otherwise treat self.date as a base date that we're modifying
142         # with an offset.
143         if not "delta_int" in self.context:
144             raise ParseException("Missing delta_int?!")
145         count = self.context["delta_int"]
146         if count == 0:
147             return
148
149         # Adjust count's sign based on the presence of 'before' or 'after'.
150         if "delta_before_after" in self.context:
151             before_after = self.context["delta_before_after"].lower()
152             if before_after == "before":
153                 count = -count
154
155         # What are we counting units of?
156         if "delta_unit" not in self.context:
157             raise ParseException("Missing delta_unit?!")
158         unit = self.context["delta_unit"]
159         if unit == DateParser.CONSTANT_DAYS:
160             timedelta = datetime.timedelta(days=count)
161             self.date = self.date + timedelta
162         elif unit == DateParser.CONSTANT_WEEKS:
163             timedelta = datetime.timedelta(weeks=count)
164             self.date = self.date + timedelta
165         else:
166             direction = 1 if count > 0 else -1
167             count = abs(count)
168             timedelta = datetime.timedelta(days=direction)
169
170             while True:
171                 dow = self.date.weekday()
172                 if dow == unit:
173                     count -= 1
174                     if count == 0:
175                         return
176                 self.date = self.date + timedelta
177
178     def enterDeltaInt(self, ctx: dateparse_utilsParser.DeltaIntContext) -> None:
179         try:
180             i = int(ctx.getText())
181         except:
182             raise ParseException(f"Bad delta int: {ctx.getText()}")
183         else:
184             self.context["delta_int"] = i
185
186     def enterDeltaUnit(
187         self, ctx: dateparse_utilsParser.DeltaUnitContext
188     ) -> None:
189         try:
190             txt = ctx.getText().lower()[:3]
191             if txt in self.day_name_to_number:
192                 txt = self.day_name_to_number[txt]
193             elif txt in self.delta_unit_to_constant:
194                 txt = self.delta_unit_to_constant[txt]
195             else:
196                 raise ParseException(f"Bad delta unit: {ctx.getText()}")
197         except:
198             raise ParseException(f"Bad delta unit: {ctx.getText()}")
199         else:
200             self.context["delta_unit"] = txt
201
202     def enterDeltaBeforeAfter(
203         self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
204     ) -> None:
205         try:
206             txt = ctx.getText().lower()
207         except:
208             raise ParseException(f"Bad delta before|after: {ctx.getText()}")
209         else:
210             self.context["delta_before_after"] = txt
211
212     def exitNthWeekdayInMonthMaybeYearExpr(
213         self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext
214     ) -> None:
215         """Do a bunch of work to convert expressions like...
216
217         'the 2nd Friday of June' -and-
218         'the last Wednesday in October'
219
220         ...into base + offset expressions instead.
221         """
222         try:
223             if "nth" not in self.context:
224                 raise ParseException(f"Missing nth number: {ctx.getText()}")
225             n = self.context["nth"]
226             if n < 1 or n > 5:  # months never have more than 5 Foodays
227                 if n != -1:
228                     raise ParseException(f"Invalid nth number: {ctx.getText()}")
229             del self.context["nth"]
230             self.context["delta_int"] = n
231
232             year = self.context.get("year", datetime.date.today().year)
233             if "month" not in self.context:
234                 raise ParseException(
235                     f"Missing month expression: {ctx.getText()}"
236                 )
237             month = self.context["month"]
238
239             dow = self.context["dow"]
240             del self.context["dow"]
241             self.context["delta_unit"] = dow
242
243             # For the nth Fooday in Month, start at the 1st of the
244             # month and count ahead N Foodays.  For the last Fooday in
245             # Month, start at the last of the month and count back one
246             # Fooday.
247             if n == -1:
248                 month += 1
249                 if month == 13:
250                     month = 1
251                     year += 1
252                 tmp_date = datetime.date(year=year, month=month, day=1)
253                 tmp_date = tmp_date - datetime.timedelta(days=1)
254
255                 self.context["year"] = tmp_date.year
256                 self.context["month"] = tmp_date.month
257                 self.context["day"] = tmp_date.day
258
259                 # The delta adjustment code can handle the case where
260                 # the last day of the month is the day we're looking
261                 # for already.
262             else:
263                 self.context["year"] = year
264                 self.context["month"] = month
265                 self.context["day"] = 1
266             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
267         except:
268             raise ParseException(
269                 f"Invalid nthWeekday expression: {ctx.getText()}"
270             )
271
272     def exitFirstLastWeekdayInMonthMaybeYearExpr(
273         self,
274         ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext,
275     ) -> None:
276         self.exitNthWeekdayInMonthMaybeYearExpr(ctx)
277
278     def enterNth(self, ctx: dateparse_utilsParser.NthContext) -> None:
279         try:
280             i = ctx.getText()
281             m = re.match("\d+[a-z][a-z]", i)
282             if m is not None:
283                 i = i[:-2]
284             i = int(i)
285         except:
286             raise ParseException(f"Bad nth expression: {ctx.getText()}")
287         else:
288             self.context["nth"] = i
289
290     def enterFirstOrLast(
291         self, ctx: dateparse_utilsParser.FirstOrLastContext
292     ) -> None:
293         try:
294             txt = ctx.getText()
295             if txt == "first":
296                 txt = 1
297             elif txt == "last":
298                 txt = -1
299             else:
300                 raise ParseException(
301                     f"Bad first|last expression: {ctx.getText()}"
302                 )
303         except:
304             raise ParseException(f"Bad first|last expression: {ctx.getText()}")
305         else:
306             self.context["nth"] = txt
307
308     def enterDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None:
309         try:
310             dow = ctx.getText().lower()[:3]
311             dow = self.day_name_to_number.get(dow, None)
312         except:
313             raise ParseException("Bad day of week")
314         else:
315             self.context["dow"] = dow
316
317     def enterDayOfMonth(
318         self, ctx: dateparse_utilsParser.DayOfMonthContext
319     ) -> None:
320         try:
321             day = int(ctx.getText())
322             if day < 1 or day > 31:
323                 raise ParseException(
324                     f"Bad dayOfMonth expression: {ctx.getText()}"
325                 )
326         except:
327             raise ParseException(f"Bad dayOfMonth expression: {ctx.getText()}")
328         self.context["day"] = day
329
330     def enterMonthName(
331         self, ctx: dateparse_utilsParser.MonthNameContext
332     ) -> None:
333         try:
334             month = ctx.getText()
335             month = month.lower()[:3]
336             month = self.month_name_to_number.get(month, None)
337             if month is None:
338                 raise ParseException(
339                     f"Bad monthName expression: {ctx.getText()}"
340                 )
341         except:
342             raise ParseException(f"Bad monthName expression: {ctx.getText()}")
343         else:
344             self.context["month"] = month
345
346     def enterMonthNumber(
347         self, ctx: dateparse_utilsParser.MonthNumberContext
348     ) -> None:
349         try:
350             month = int(ctx.getText())
351             if month < 1 or month > 12:
352                 raise ParseException(
353                     f"Bad monthNumber expression: {ctx.getText()}"
354                 )
355         except:
356             raise ParseException(f"Bad monthNumber expression: {ctx.getText()}")
357         else:
358             self.context["month"] = month
359
360     def enterYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
361         try:
362             year = int(ctx.getText())
363             if year < 1:
364                 raise ParseException(f"Bad year expression: {ctx.getText()}")
365         except:
366             raise ParseException(f"Bad year expression: {ctx.getText()}")
367         else:
368             self.context["year"] = year
369
370     def enterSpecialDate(
371         self, ctx: dateparse_utilsParser.SpecialDateContext
372     ) -> None:
373         try:
374             txt = ctx.getText().lower()
375         except:
376             raise ParseException(f"Bad specialDate expression: {ctx.getText()}")
377         else:
378             self.context["special"] = txt
379
380
381 def main() -> None:
382     parser = DateParser()
383     for line in sys.stdin:
384         line = line.strip()
385         line = line.lower()
386         line = re.sub(r"#.*$", "", line)
387         if re.match(r"^ *$", line) is not None:
388             continue
389         print(parser.parse_date_string(line))
390     sys.exit(0)
391
392
393 if __name__ == "__main__":
394     main()