summaryrefslogtreecommitdiff
path: root/dateparse
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2021-03-24 18:08:54 -0700
committerScott Gasch <[email protected]>2021-03-24 18:08:54 -0700
commit497fb9e21f45ec08e1486abaee6dfa7b20b8a691 (patch)
tree47aa97a0fca36c4e7025cee5ad4e9ec6db49b881 /dateparse
Initial revision
Diffstat (limited to 'dateparse')
-rw-r--r--dateparse/dateparse_utils.g4328
-rwxr-xr-xdateparse/dateparse_utils.py394
2 files changed, 722 insertions, 0 deletions
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()