From 497fb9e21f45ec08e1486abaee6dfa7b20b8a691 Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Wed, 24 Mar 2021 18:08:54 -0700 Subject: Initial revision --- dateparse/dateparse_utils.g4 | 328 +++++++++++++++++++++++++++++++++++ dateparse/dateparse_utils.py | 394 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 dateparse/dateparse_utils.g4 create mode 100755 dateparse/dateparse_utils.py (limited to 'dateparse') diff --git a/dateparse/dateparse_utils.g4 b/dateparse/dateparse_utils.g4 new file mode 100644 index 0000000..86a9cd4 --- /dev/null +++ b/dateparse/dateparse_utils.g4 @@ -0,0 +1,328 @@ +// antlr4 -Dlanguage=Python3 ./dateparse_utils.g4 + +// Hi, self. In ANTLR grammars, there are two separate types of symbols: those +// for the lexer and those for the parser. The former begin with a CAPITAL +// whereas the latter begin with lowercase. The order of the lexer symbols +// is the order that the lexer will recognize them in. There's a good tutorial +// on this shit at: +// +// https://tomassetti.me/antlr-mega-tutorial/ +// +// There are also a zillion premade grammars at: +// +// https://github.com/antlr/grammars-v4 + +grammar dateparse_utils; + +parse: dateExpr ; + +dateExpr + : singleDateExpr + | baseAndOffsetDateExpr + ; + +singleDateExpr + : monthDayMaybeYearExpr + | dayMonthMaybeYearExpr + | yearMonthDayExpr + | specialDateMaybeYearExpr + | nthWeekdayInMonthMaybeYearExpr + | firstLastWeekdayInMonthMaybeYearExpr + ; + +monthDayMaybeYearExpr + : monthExpr DIV* dayOfMonth (DIV* year)? + ; + +dayMonthMaybeYearExpr + : dayOfMonth DIV* monthName (DIV* year)? + ; + +yearMonthDayExpr + : year DIV* monthName DIV* dayOfMonth + ; + +nthWeekdayInMonthMaybeYearExpr + : nth dayName ('in'|'of') monthName (DIV* year)? + ; + +firstLastWeekdayInMonthMaybeYearExpr + : firstOrLast dayName ('in'|'of'|DIV)? monthName (DIV* year)? + ; + +specialDateMaybeYearExpr + : specialDate (DIV* year)? + ; + +baseAndOffsetDateExpr + : baseDate deltaPlusMinusExpr + | deltaPlusMinusExpr baseDate + ; + +baseDate: singleDateExpr ; + +deltaPlusMinusExpr: deltaInt deltaUnit deltaBeforeAfter? ; + +deltaUnit: (WEEK|DAY|SUN|WEEKDAY) ; + +deltaBeforeAfter: (BEFORE|AFTER) ; + +monthExpr + : monthName + | monthNumber + ; + +year: DIGIT DIGIT DIGIT DIGIT ; + +specialDate: SPECIAL_DATE ; + +dayOfMonth: DIGIT? DIGIT ('st'|'nd'|'rd'|'th')? ; + +firstOrLast: (FIRST|LAST) ; + +nth: DIGIT ('st'|'nd'|'rd'|'th')? ; + +deltaInt: ('+'|'-')? DIGIT+ ; + +dayName: WEEKDAY ; + +monthName: MONTH ; + +monthNumber: DIGIT? DIGIT ; + +// ---------------------------------- + +COMMENT: '#' ~[\r\n]* -> skip ; + +SPACE: [ \t\r\n] -> skip ; + +THE: 'the' -> skip ; + +DIV: ('/'|','|'.') ; + +MONTH: (JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC) ; + +JAN : 'jan' + | 'january' + ; + +FEB : 'feb' + | 'february' + ; + +MAR : 'mar' + | 'march' + ; + +APR : 'apr' + | 'april' + ; + +MAY : 'may' + ; + +JUN : 'jun' + | 'june' + ; + +JUL : 'jul' + | 'july' + ; + +AUG : 'aug' + | 'august' + ; + +SEP : 'sep' + | 'sept' + | 'september' + ; + +OCT : 'oct' + | 'october' + ; + +NOV : 'nov' + | 'november' + ; + +DEC : 'dec' + | 'december' + ; + +WEEKDAY: (SUN|MON|TUE|WED|THU|FRI|SAT) ; + +SUN : 'sun' + | 'suns' + | 'sunday' + | 'sundays' + ; + +MON : 'mon' + | 'mons' + | 'monday' + | 'mondays' + ; + +TUE : 'tue' + | 'tues' + | 'tuesday' + | 'tuesdays' + ; + +WED : 'wed' + | 'weds' + | 'wednesday' + | 'wednesdays' + ; + +THU : 'thu' + | 'thur' + | 'thurs' + | 'thursday' + | 'thursdays' + ; + +FRI : 'fri' + | 'fris' + | 'friday' + | 'fridays' + ; + +SAT : 'sat' + | 'sats' + | 'saturday' + | 'saturdays' + ; + +WEEK + : 'week' + | 'weeks' + ; + +DAY + : 'day' + | 'days' + ; + +SPECIAL_DATE + : TODAY + | NEW_YEARS_EVE + | NEW_YEARS_DAY + | MARTIN_LUTHER_KING_DAY + | PRESIDENTS_DAY + | EASTER + | MEMORIAL_DAY + | INDEPENDENCE_DAY + | LABOR_DAY + | COLUMBUS_DAY + | VETERANS_DAY + | THANKSGIVING_DAY + | CHRISTMAS_EVE + | CHRISTMAS + ; + +// today +TODAY + : 'today' + ; + +// easte +EASTER + : 'easter' + | 'easter sunday' + ; + +// newye +NEW_YEARS_DAY + : 'new years' + | 'new years day' + | 'new year\'s' + | 'new year\'s day' + ; + +// newyeeve +NEW_YEARS_EVE + : 'nye' + | 'new years eve' + | 'new year\'s eve' + ; + +// chris +CHRISTMAS + : 'christmas' + | 'christmas day' + | 'xmas' + | 'xmas day' + ; + +// chriseve +CHRISTMAS_EVE + : 'christmas eve' + | 'xmas eve' + ; + +// mlk +MARTIN_LUTHER_KING_DAY + : 'martin luther king day' + | 'mlk day' + | 'mlk' + ; + +// memor +MEMORIAL_DAY + : 'memorial' + | 'memorial day' + ; + +// indep +INDEPENDENCE_DAY + : 'independence day' + ; + +// labor +LABOR_DAY + : 'labor' + | 'labor day' + ; + +// presi +PRESIDENTS_DAY + : 'presidents\' day' + | 'president\'s day' + | 'presidents day' + | 'presidents' + | 'president\'s' + | 'presidents\'' + ; + +// colum +COLUMBUS_DAY + : 'columbus' + | 'columbus day' + | 'indiginous peoples day' + | 'indiginous peoples\' day' + ; + +// veter +VETERANS_DAY + : 'veterans' + | 'veterans day' + | 'veterans\' day' + ; + +// thank +THANKSGIVING_DAY + : 'thanksgiving' + | 'thanksgiving day' + ; + +FIRST: 'first' ; + +LAST: 'last' ; + +BEFORE: 'before' ; + +AFTER: ('after'|'from') ; + +DIGIT: ('0'..'9') ; diff --git a/dateparse/dateparse_utils.py b/dateparse/dateparse_utils.py new file mode 100755 index 0000000..fb55eef --- /dev/null +++ b/dateparse/dateparse_utils.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 + +import antlr4 # type: ignore +import datetime +import dateutil.easter +import holidays # type: ignore +import re +import sys +from typing import Any, Dict, Optional + +from dateparse.dateparse_utilsLexer import dateparse_utilsLexer # type: ignore +from dateparse.dateparse_utilsListener import dateparse_utilsListener # type: ignore +from dateparse.dateparse_utilsParser import dateparse_utilsParser # type: ignore + + +class ParseException(Exception): + def __init__(self, message: str) -> None: + self.message = message + + +class DateParser(dateparse_utilsListener): + PARSE_TYPE_SINGLE_DATE_EXPR = 1 + PARSE_TYPE_BASE_AND_OFFSET_EXPR = 2 + CONSTANT_DAYS = 7 + CONSTANT_WEEKS = 8 + CONSTANT_MONTHS = 9 + CONSTANT_YEARS = 10 + + def __init__(self): + self.month_name_to_number = { + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + } + self.day_name_to_number = { + "mon": 0, + "tue": 1, + "wed": 2, + "thu": 3, + "fri": 4, + "sat": 5, + "sun": 6, + } + self.delta_unit_to_constant = { + "day": DateParser.CONSTANT_DAYS, + "wee": DateParser.CONSTANT_WEEKS, + } + self.date: Optional[datetime.date] = None + + def parse_date_string(self, date_string: str) -> Optional[datetime.date]: + input_stream = antlr4.InputStream(date_string) + lexer = dateparse_utilsLexer(input_stream) + stream = antlr4.CommonTokenStream(lexer) + parser = dateparse_utilsParser(stream) + tree = parser.parse() + walker = antlr4.ParseTreeWalker() + walker.walk(self, tree) + return self.get_date() + + def get_date(self) -> Optional[datetime.date]: + return self.date + + def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext): + self.date = None + self.context: Dict[str, Any] = {} + if ctx.singleDateExpr() is not None: + self.main_type = DateParser.PARSE_TYPE_SINGLE_DATE_EXPR + elif ctx.baseAndOffsetDateExpr() is not None: + self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR + + @staticmethod + def normalize_special_day_name(name: str) -> str: + name = name.lower() + name = name.replace("'", "") + name = name.replace("xmas", "christmas") + name = name.replace("mlk", "martin luther king") + name = name.replace(" ", "") + eve = "eve" if name[-3:] == "eve" else "" + name = name[:5] + eve + name = name.replace("washi", "presi") + return name + + def parse_special(self, name: str) -> Optional[datetime.date]: + today = datetime.date.today() + year = self.context.get("year", today.year) + name = DateParser.normalize_special_day_name(self.context["special"]) + if name == "today": + return today + if name == "easte": + return dateutil.easter.easter(year=year) + for holiday_date, holiday_name in sorted( + holidays.US(years=year).items() + ): + if "Observed" not in holiday_name: + holiday_name = DateParser.normalize_special_day_name( + holiday_name + ) + if name == holiday_name: + return holiday_date + if name == "chriseve": + return datetime.date(year=year, month=12, day=24) + elif name == "newyeeve": + return datetime.date(year=year, month=12, day=31) + return None + + def parse_normal(self) -> datetime.date: + if "month" not in self.context: + raise ParseException("Missing month") + if "day" not in self.context: + raise ParseException("Missing day") + if "year" not in self.context: + today = datetime.date.today() + self.context["year"] = today.year + return datetime.date( + year=int(self.context["year"]), + month=int(self.context["month"]), + day=int(self.context["day"]), + ) + + def exitDateExpr(self, ctx: dateparse_utilsParser.DateExprContext) -> None: + """When we leave the date expression, populate self.date.""" + if "special" in self.context: + self.date = self.parse_special(self.context["special"]) + else: + self.date = self.parse_normal() + assert self.date is not None + + # For a single date, just return the date we pulled out. + if self.main_type == DateParser.PARSE_TYPE_SINGLE_DATE_EXPR: + return + + # Otherwise treat self.date as a base date that we're modifying + # with an offset. + if not "delta_int" in self.context: + raise ParseException("Missing delta_int?!") + count = self.context["delta_int"] + if count == 0: + return + + # Adjust count's sign based on the presence of 'before' or 'after'. + if "delta_before_after" in self.context: + before_after = self.context["delta_before_after"].lower() + if before_after == "before": + count = -count + + # What are we counting units of? + if "delta_unit" not in self.context: + raise ParseException("Missing delta_unit?!") + unit = self.context["delta_unit"] + if unit == DateParser.CONSTANT_DAYS: + timedelta = datetime.timedelta(days=count) + self.date = self.date + timedelta + elif unit == DateParser.CONSTANT_WEEKS: + timedelta = datetime.timedelta(weeks=count) + self.date = self.date + timedelta + else: + direction = 1 if count > 0 else -1 + count = abs(count) + timedelta = datetime.timedelta(days=direction) + + while True: + dow = self.date.weekday() + if dow == unit: + count -= 1 + if count == 0: + return + self.date = self.date + timedelta + + def enterDeltaInt(self, ctx: dateparse_utilsParser.DeltaIntContext) -> None: + try: + i = int(ctx.getText()) + except: + raise ParseException(f"Bad delta int: {ctx.getText()}") + else: + self.context["delta_int"] = i + + def enterDeltaUnit( + self, ctx: dateparse_utilsParser.DeltaUnitContext + ) -> None: + try: + txt = ctx.getText().lower()[:3] + if txt in self.day_name_to_number: + txt = self.day_name_to_number[txt] + elif txt in self.delta_unit_to_constant: + txt = self.delta_unit_to_constant[txt] + else: + raise ParseException(f"Bad delta unit: {ctx.getText()}") + except: + raise ParseException(f"Bad delta unit: {ctx.getText()}") + else: + self.context["delta_unit"] = txt + + def enterDeltaBeforeAfter( + self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext + ) -> None: + try: + txt = ctx.getText().lower() + except: + raise ParseException(f"Bad delta before|after: {ctx.getText()}") + else: + self.context["delta_before_after"] = txt + + def exitNthWeekdayInMonthMaybeYearExpr( + self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext + ) -> None: + """Do a bunch of work to convert expressions like... + + 'the 2nd Friday of June' -and- + 'the last Wednesday in October' + + ...into base + offset expressions instead. + """ + try: + if "nth" not in self.context: + raise ParseException(f"Missing nth number: {ctx.getText()}") + n = self.context["nth"] + if n < 1 or n > 5: # months never have more than 5 Foodays + if n != -1: + raise ParseException(f"Invalid nth number: {ctx.getText()}") + del self.context["nth"] + self.context["delta_int"] = n + + year = self.context.get("year", datetime.date.today().year) + if "month" not in self.context: + raise ParseException( + f"Missing month expression: {ctx.getText()}" + ) + month = self.context["month"] + + dow = self.context["dow"] + del self.context["dow"] + self.context["delta_unit"] = dow + + # For the nth Fooday in Month, start at the 1st of the + # month and count ahead N Foodays. For the last Fooday in + # Month, start at the last of the month and count back one + # Fooday. + if n == -1: + month += 1 + if month == 13: + month = 1 + year += 1 + tmp_date = datetime.date(year=year, month=month, day=1) + tmp_date = tmp_date - datetime.timedelta(days=1) + + self.context["year"] = tmp_date.year + self.context["month"] = tmp_date.month + self.context["day"] = tmp_date.day + + # The delta adjustment code can handle the case where + # the last day of the month is the day we're looking + # for already. + else: + self.context["year"] = year + self.context["month"] = month + self.context["day"] = 1 + self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR + except: + raise ParseException( + f"Invalid nthWeekday expression: {ctx.getText()}" + ) + + def exitFirstLastWeekdayInMonthMaybeYearExpr( + self, + ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext, + ) -> None: + self.exitNthWeekdayInMonthMaybeYearExpr(ctx) + + def enterNth(self, ctx: dateparse_utilsParser.NthContext) -> None: + try: + i = ctx.getText() + m = re.match("\d+[a-z][a-z]", i) + if m is not None: + i = i[:-2] + i = int(i) + except: + raise ParseException(f"Bad nth expression: {ctx.getText()}") + else: + self.context["nth"] = i + + def enterFirstOrLast( + self, ctx: dateparse_utilsParser.FirstOrLastContext + ) -> None: + try: + txt = ctx.getText() + if txt == "first": + txt = 1 + elif txt == "last": + txt = -1 + else: + raise ParseException( + f"Bad first|last expression: {ctx.getText()}" + ) + except: + raise ParseException(f"Bad first|last expression: {ctx.getText()}") + else: + self.context["nth"] = txt + + def enterDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None: + try: + dow = ctx.getText().lower()[:3] + dow = self.day_name_to_number.get(dow, None) + except: + raise ParseException("Bad day of week") + else: + self.context["dow"] = dow + + def enterDayOfMonth( + self, ctx: dateparse_utilsParser.DayOfMonthContext + ) -> None: + try: + day = int(ctx.getText()) + if day < 1 or day > 31: + raise ParseException( + f"Bad dayOfMonth expression: {ctx.getText()}" + ) + except: + raise ParseException(f"Bad dayOfMonth expression: {ctx.getText()}") + self.context["day"] = day + + def enterMonthName( + self, ctx: dateparse_utilsParser.MonthNameContext + ) -> None: + try: + month = ctx.getText() + month = month.lower()[:3] + month = self.month_name_to_number.get(month, None) + if month is None: + raise ParseException( + f"Bad monthName expression: {ctx.getText()}" + ) + except: + raise ParseException(f"Bad monthName expression: {ctx.getText()}") + else: + self.context["month"] = month + + def enterMonthNumber( + self, ctx: dateparse_utilsParser.MonthNumberContext + ) -> None: + try: + month = int(ctx.getText()) + if month < 1 or month > 12: + raise ParseException( + f"Bad monthNumber expression: {ctx.getText()}" + ) + except: + raise ParseException(f"Bad monthNumber expression: {ctx.getText()}") + else: + self.context["month"] = month + + def enterYear(self, ctx: dateparse_utilsParser.YearContext) -> None: + try: + year = int(ctx.getText()) + if year < 1: + raise ParseException(f"Bad year expression: {ctx.getText()}") + except: + raise ParseException(f"Bad year expression: {ctx.getText()}") + else: + self.context["year"] = year + + def enterSpecialDate( + self, ctx: dateparse_utilsParser.SpecialDateContext + ) -> None: + try: + txt = ctx.getText().lower() + except: + raise ParseException(f"Bad specialDate expression: {ctx.getText()}") + else: + self.context["special"] = txt + + +def main() -> None: + parser = DateParser() + for line in sys.stdin: + line = line.strip() + line = line.lower() + line = re.sub(r"#.*$", "", line) + if re.match(r"^ *$", line) is not None: + continue + print(parser.parse_date_string(line)) + sys.exit(0) + + +if __name__ == "__main__": + main() -- cgit v1.3