#!/usr/bin/env python3
-import antlr4 # type: ignore
import datetime
-import dateutil.easter
+import functools
import holidays # type: ignore
+import logging
import re
import sys
-from typing import Any, Dict, Optional
+from typing import Any, Callable, Dict, Optional
+
+import antlr4 # type: ignore
+import dateutil.easter
+import dateutil.tz
+import pytz
+import acl
+import bootstrap
+from datetime_utils import (
+ TimeUnit, n_timeunits_from_base, datetime_to_date, date_to_datetime
+)
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
+import decorator_utils
+
+
+logger = logging.getLogger(__name__)
+
+
+def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
+ @functools.wraps(enter_or_exit_f)
+ def debug_parse_wrapper(*args, **kwargs):
+ slf = args[0]
+ ctx = args[1]
+ depth = ctx.depth()
+ logger.debug(
+ ' ' * (depth-1) +
+ f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
+ )
+ for c in ctx.getChildren():
+ logger.debug(
+ ' ' * (depth-1) +
+ f'{c} {type(c)}'
+ )
+ retval = enter_or_exit_f(*args, **kwargs)
+ return retval
+ return debug_parse_wrapper
class ParseException(Exception):
+ """An exception thrown during parsing because of unrecognized input."""
def __init__(self, message: str) -> None:
self.message = message
+class RaisingErrorListener(antlr4.DiagnosticErrorListener):
+ """An error listener that raises ParseExceptions."""
+ def syntaxError(
+ self, recognizer, offendingSymbol, line, column, msg, e
+ ):
+ raise ParseException(msg)
+
+ def reportAmbiguity(
+ self, recognizer, dfa, startIndex, stopIndex, exact,
+ ambigAlts, configs
+ ):
+ pass
+
+ def reportAttemptingFullContext(
+ self, recognizer, dfa, startIndex, stopIndex, conflictingAlts,
+ configs
+ ):
+ pass
+
+ def reportContextSensitivity(
+ self, recognizer, dfa, startIndex, stopIndex, prediction, configs
+ ):
+ pass
+
+
+@decorator_utils.decorate_matching_methods_with(
+ debug_parse,
+ acl=acl.StringWildcardBasedACL(
+ allowed_patterns=[
+ 'enter*',
+ 'exit*',
+ ],
+ denied_patterns=[
+ 'enterEveryRule',
+ 'exitEveryRule'
+ ],
+ order_to_check_allow_deny=acl.Order.DENY_ALLOW,
+ default_answer=False
+ )
+)
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
+ PARSE_TYPE_SINGLE_TIME_EXPR = 3
+ PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR = 4
- def __init__(self):
+ def __init__(
+ self,
+ *,
+ override_now_for_test_purposes = None
+ ) -> None:
+ """C'tor. Passing a value to override_now_for_test_purposes can be
+ used to force this instance to use a custom date/time for its
+ idea of "now" so that the code can be more easily unittested.
+ Leave as None for real use cases.
+ """
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,
+ 'jan': 1,
+ 'feb': 2,
+ 'mar': 3,
+ 'apr': 4,
+ 'may': 5,
+ 'jun': 6,
+ 'jul': 7,
+ 'aug': 8,
+ 'sep': 9,
+ 'oct': 10,
+ 'nov': 11,
+ 'dec': 12,
}
+
+ # Used only for ides/nones. Month length on a non-leap year.
+ self.typical_days_per_month = {
+ 1: 31,
+ 2: 28,
+ 3: 31,
+ 4: 30,
+ 5: 31,
+ 6: 30,
+ 7: 31,
+ 8: 31,
+ 9: 30,
+ 10: 31,
+ 11: 30,
+ 12: 31
+ }
+
+ # N.B. day number is also synched with datetime_utils.TimeUnit values
+ # which allows expressions like "3 wednesdays from now" to work.
self.day_name_to_number = {
- "mon": 0,
- "tue": 1,
- "wed": 2,
- "thu": 3,
- "fri": 4,
- "sat": 5,
- "sun": 6,
+ 'mon': 0,
+ 'tue': 1,
+ 'wed': 2,
+ 'thu': 3,
+ 'fri': 4,
+ 'sat': 5,
+ 'sun': 6,
+ }
+
+ # These TimeUnits are defined in datetime_utils and are used as params
+ # to datetime_utils.n_timeunits_from_base.
+ self.time_delta_unit_to_constant = {
+ 'hou': TimeUnit.HOURS,
+ 'min': TimeUnit.MINUTES,
+ 'sec': TimeUnit.SECONDS,
}
self.delta_unit_to_constant = {
- "day": DateParser.CONSTANT_DAYS,
- "wee": DateParser.CONSTANT_WEEKS,
+ 'day': TimeUnit.DAYS,
+ 'wor': TimeUnit.WORKDAYS,
+ 'wee': TimeUnit.WEEKS,
+ 'mon': TimeUnit.MONTHS,
+ 'yea': TimeUnit.YEARS,
}
- self.date: Optional[datetime.date] = None
+ self.override_now_for_test_purposes = override_now_for_test_purposes
+ self._reset()
- def parse_date_string(self, date_string: str) -> Optional[datetime.date]:
+ def parse(self, date_string: str) -> Optional[datetime.datetime]:
+ """Parse a date/time expression and return a timezone agnostic
+ datetime on success. Also sets self.datetime, self.date and
+ self.time which can each be accessed other methods on the
+ class: get_datetime(), get_date() and get_time(). Raises a
+ ParseException with a helpful(?) message on parse error or
+ confusion.
+
+ To get an idea of what expressions can be parsed, check out
+ the unittest and the grammar.
+
+ Usage:
+
+ txt = '3 weeks before last tues at 9:15am'
+ dp = DateParser()
+ dt1 = dp.parse(txt)
+ dt2 = dp.get_datetime(tz=pytz.timezone('US/Pacific'))
+
+ # dt1 and dt2 will be identical other than the fact that
+ # the latter's tzinfo will be set to PST/PDT.
+
+ This is the main entrypoint to this class for caller code.
+ """
+ date_string = date_string.strip()
+ date_string = re.sub('\s+', ' ', date_string)
+ self._reset()
+ listener = RaisingErrorListener()
input_stream = antlr4.InputStream(date_string)
lexer = dateparse_utilsLexer(input_stream)
+ lexer.removeErrorListeners()
+ lexer.addErrorListener(listener)
stream = antlr4.CommonTokenStream(lexer)
parser = dateparse_utilsParser(stream)
+ parser.removeErrorListeners()
+ parser.addErrorListener(listener)
tree = parser.parse()
walker = antlr4.ParseTreeWalker()
walker.walk(self, tree)
- return self.get_date()
+ return self.datetime
def get_date(self) -> Optional[datetime.date]:
+ """Return the date part or None."""
return self.date
- def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
- self.date = None
+ def get_time(self) -> Optional[datetime.time]:
+ """Return the time part or None."""
+ return self.time
+
+ def get_datetime(self, *, tz=None) -> Optional[datetime.datetime]:
+ """Return as a datetime. Parsed date expressions without any time
+ part return hours = minutes = seconds = microseconds = 0 (i.e. at
+ midnight that day). Parsed time expressions without any date part
+ default to date = today.
+
+ The optional tz param allows the caller to request the datetime be
+ timezone aware and sets the tzinfo to the indicated zone. Defaults
+ to timezone naive (i.e. tzinfo = None).
+ """
+ dt = self.datetime
+ if tz is not None:
+ dt = dt.replace(tzinfo=None).astimezone(tz=tz)
+ return dt
+
+ # -- helpers --
+
+ def _reset(self):
+ """Reset at init and between parses."""
+ if self.override_now_for_test_purposes is None:
+ self.now_datetime = datetime.datetime.now()
+ self.today = datetime.date.today()
+ else:
+ self.now_datetime = self.override_now_for_test_purposes
+ self.today = datetime_to_date(
+ self.override_now_for_test_purposes
+ )
+ self.date: Optional[datetime.date] = None
+ self.time: Optional[datetime.time] = None
+ self.datetime: Optional[datetime.datetime] = 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
+ self.timedelta = datetime.timedelta(seconds=0)
@staticmethod
- def normalize_special_day_name(name: str) -> str:
+ def _normalize_special_day_name(name: str) -> str:
+ """String normalization / canonicalization for date expressions."""
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.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")
+ 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":
+ def _figure_out_date_unit(self, orig: str) -> TimeUnit:
+ """Figure out what unit a date expression piece is talking about."""
+ if 'month' in orig:
+ return TimeUnit.MONTHS
+ txt = orig.lower()[:3]
+ if txt in self.day_name_to_number:
+ return(TimeUnit(self.day_name_to_number[txt]))
+ elif txt in self.delta_unit_to_constant:
+ return(TimeUnit(self.delta_unit_to_constant[txt]))
+ raise ParseException(f'Invalid date unit: {orig}')
+
+ def _figure_out_time_unit(self, orig: str) -> int:
+ """Figure out what unit a time expression piece is talking about."""
+ txt = orig.lower()[:3]
+ if txt in self.time_delta_unit_to_constant:
+ return(self.time_delta_unit_to_constant[txt])
+ raise ParseException(f'Invalid time unit: {orig}')
+
+ def _parse_special_date(self, name: str) -> Optional[datetime.date]:
+ """Parse what we think is a special date name and return its datetime
+ (or None if it can't be parsed).
+ """
+ today = self.today
+ year = self.context.get('year', today.year)
+ name = DateParser._normalize_special_day_name(self.context['special'])
+
+ # Yesterday, today, tomorrow -- ignore any next/last
+ if name == 'today' or name == 'now':
return today
- if name == "easte":
+ if name == 'yeste':
+ return today + datetime.timedelta(days=-1)
+ if name == 'tomor':
+ return today + datetime.timedelta(days=+1)
+
+ next_last = self.context.get('special_next_last', '')
+ if next_last == 'next':
+ year += 1
+ elif next_last == 'last':
+ year -= 1
+
+ # Holiday names
+ if name == 'easte':
return dateutil.easter.easter(year=year)
+ elif name == 'hallo':
+ return datetime.date(year=year, month=10, day=31)
+
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(
+ 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":
+ if name == 'chriseve':
return datetime.date(year=year, month=12, day=24)
- elif name == "newyeeve":
+ 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
+ def _resolve_ides_nones(self, day: str, month_number: int) -> int:
+ """Handle date expressions like "the ides of March" which require
+ both the "ides" and the month since the definition of the "ides"
+ changes based on the length of the month.
+ """
+ assert 'ide' in day or 'non' in day
+ assert month_number in self.typical_days_per_month
+ typical_days_per_month = self.typical_days_per_month[month_number]
+
+ # "full" month
+ if typical_days_per_month == 31:
+ if self.context['day'] == 'ide':
+ return 15
+ else:
+ return 7
+
+ # "hollow" month
+ else:
+ if self.context['day'] == 'ide':
+ return 13
+ else:
+ return 5
+
+ def _parse_normal_date(self) -> datetime.date:
+ if 'dow' in self.context:
+ d = self.today
+ while d.weekday() != self.context['dow']:
+ d += datetime.timedelta(days=1)
+ return d
+
+ 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:
+ self.context['year'] = self.today.year
+
+ # Handling "ides" and "nones" requires both the day and month.
+ if (
+ self.context['day'] == 'ide' or
+ self.context['day'] == 'non'
+ ):
+ self.context['day'] = self._resolve_ides_nones(
+ self.context['day'], self.context['month']
+ )
+
return datetime.date(
- year=int(self.context["year"]),
- month=int(self.context["month"]),
- day=int(self.context["day"]),
+ year=self.context['year'],
+ month=self.context['month'],
+ day=self.context['day'],
)
+ def _parse_tz(self, txt: str) -> Any:
+ if txt == 'Z':
+ txt = 'UTC'
+
+ # Try pytz
+ try:
+ tz = pytz.timezone(txt)
+ if tz is not None:
+ return tz
+ except Exception:
+ pass
+
+ # Try dateutil
+ try:
+ tz = dateutil.tz.gettz(txt)
+ if tz is not None:
+ return tz
+ except Exception:
+ pass
+
+ # Try constructing an offset in seconds
+ try:
+ sign = txt[0]
+ if sign == '-' or sign == '+':
+ sign = +1 if sign == '+' else -1
+ hour = int(txt[1:3])
+ minute = int(txt[-2:])
+ offset = sign * (hour * 60 * 60) + sign * (minute * 60)
+ tzoffset = dateutil.tz.tzoffset(txt, offset)
+ return tzoffset
+ except Exception:
+ pass
+ return None
+
+ def _get_int(self, txt: str) -> int:
+ while not txt[0].isdigit() and txt[0] != '-' and txt[0] != '+':
+ txt = txt[1:]
+ while not txt[-1].isdigit():
+ txt = txt[:-1]
+ return int(txt)
+
+ # -- overridden methods invoked by parse walk --
+
+ def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
+ pass
+
+ def visitTerminal(self, node: antlr4.TerminalNode) -> None:
+ pass
+
+ def exitParse(self, ctx: dateparse_utilsParser.ParseContext) -> None:
+ """Populate self.datetime."""
+ if self.date is None:
+ self.date = self.today
+ year = self.date.year
+ month = self.date.month
+ day = self.date.day
+
+ if self.time is None:
+ self.time = datetime.time(0, 0, 0)
+ hour = self.time.hour
+ minute = self.time.minute
+ second = self.time.second
+ micros = self.time.microsecond
+
+ self.datetime = datetime.datetime(
+ year, month, day, hour, minute, second, micros,
+ tzinfo=self.time.tzinfo
+ )
+
+ # Apply resudual adjustments to times here when we have a
+ # datetime.
+ self.datetime = self.datetime + self.timedelta
+ self.time = datetime.time(
+ self.datetime.hour,
+ self.datetime.minute,
+ self.datetime.second,
+ self.datetime.microsecond,
+ self.datetime.tzinfo
+ )
+
+ def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
+ self.date = None
+ 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
+
+ def enterTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext):
+ self.time = None
+ if ctx.singleTimeExpr() is not None:
+ self.time_type = DateParser.PARSE_TYPE_SINGLE_TIME_EXPR
+ elif ctx.baseAndOffsetTimeExpr() is not None:
+ self.time_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR
+
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"])
+ if 'special' in self.context:
+ self.date = self._parse_special_date(self.context['special'])
else:
- self.date = self.parse_normal()
+ self.date = self._parse_normal_date()
assert self.date is not None
# For a single date, just return the date we pulled out.
# 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 'delta_int' not 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":
+ if 'delta_before_after' in self.context:
+ before_after = self.context['delta_before_after'].lower()
+ if (
+ before_after == 'before' or
+ before_after == 'until' or
+ before_after == 'til' or
+ before_after == 'to'
+ ):
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
+ if 'delta_unit' not in self.context:
+ raise ParseException('Missing delta_unit?!')
+ unit = self.context['delta_unit']
+ dt = n_timeunits_from_base(
+ count,
+ TimeUnit(unit),
+ date_to_datetime(self.date)
+ )
+ self.date = datetime_to_date(dt)
+
+ def exitTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext) -> None:
+ # Simple time?
+ self.time = datetime.time(
+ self.context['hour'],
+ self.context['minute'],
+ self.context['seconds'],
+ self.context['micros'],
+ tzinfo=self.context.get('tz', None),
+ )
+ if self.time_type == DateParser.PARSE_TYPE_SINGLE_TIME_EXPR:
+ return
+
+ # If we get here there (should be) a relative adjustment to
+ # the time.
+ if 'nth' in self.context:
+ count = self.context['nth']
+ elif 'time_delta_int' in self.context:
+ count = self.context['time_delta_int']
+ else:
+ raise ParseException('Missing delta in relative time.')
+ if count == 0:
+ return
+
+ # Adjust count's sign based on the presence of 'before' or 'after'.
+ if 'time_delta_before_after' in self.context:
+ before_after = self.context['time_delta_before_after'].lower()
+ if (
+ before_after == 'before' or
+ before_after == 'until' or
+ before_after == 'til' or
+ before_after == 'to'
+ ):
+ count = -count
+
+ # What are we counting units of... assume minutes.
+ if 'time_delta_unit' not in self.context:
+ self.timedelta += datetime.timedelta(minutes=count)
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()}")
+ unit = self.context['time_delta_unit']
+ if unit == TimeUnit.SECONDS:
+ self.timedelta += datetime.timedelta(seconds=count)
+ elif unit == TimeUnit.MINUTES:
+ self.timedelta = datetime.timedelta(minutes=count)
+ elif unit == TimeUnit.HOURS:
+ self.timedelta = datetime.timedelta(hours=count)
+ else:
+ raise ParseException()
+
+ def exitDeltaPlusMinusExpr(
+ self, ctx: dateparse_utilsParser.DeltaPlusMinusExprContext
+ ) -> None:
+ try:
+ n = ctx.nth()
+ if n is None:
+ raise ParseException(
+ f'Bad N in Delta +/- Expr: {ctx.getText()}'
+ )
+ n = n.getText()
+ n = self._get_int(n)
+ unit = self._figure_out_date_unit(
+ ctx.deltaUnit().getText().lower()
+ )
+ except Exception:
+ raise ParseException(f'Invalid Delta +/-: {ctx.getText()}')
else:
- self.context["delta_int"] = i
+ self.context['delta_int'] = n
+ self.context['delta_unit'] = unit
- def enterDeltaUnit(
+ def exitNextLastUnit(
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]
+ unit = self._figure_out_date_unit(ctx.getText().lower())
+ except Exception:
+ raise ParseException(f'Bad delta unit: {ctx.getText()}')
+ else:
+ self.context['delta_unit'] = unit
+
+ def exitDeltaNextLast(
+ self, ctx: dateparse_utilsParser.DeltaNextLastContext
+ ) -> None:
+ try:
+ txt = ctx.getText().lower()
+ except Exception:
+ raise ParseException(f'Bad next/last: {ctx.getText()}')
+ if (
+ 'month' in self.context or
+ 'day' in self.context or
+ 'year' in self.context
+ ):
+ raise ParseException(
+ 'Next/last expression expected to be relative to today.'
+ )
+ if txt[:4] == 'next':
+ self.context['delta_int'] = +1
+ self.context['day'] = self.now_datetime.day
+ self.context['month'] = self.now_datetime.month
+ self.context['year'] = self.now_datetime.year
+ elif txt[:4] == 'last':
+ self.context['delta_int'] = -1
+ self.context['day'] = self.now_datetime.day
+ self.context['month'] = self.now_datetime.month
+ self.context['year'] = self.now_datetime.year
+ else:
+ raise ParseException(f'Bad next/last: {ctx.getText()}')
+
+ def exitCountUnitsBeforeAfterTimeExpr(
+ self, ctx: dateparse_utilsParser.CountUnitsBeforeAfterTimeExprContext
+ ) -> None:
+ if 'nth' not in self.context:
+ raise ParseException(
+ f'Bad count expression: {ctx.getText()}'
+ )
+ try:
+ unit = self._figure_out_time_unit(
+ ctx.deltaTimeUnit().getText().lower()
+ )
+ self.context['time_delta_unit'] = unit
+ except Exception:
+ raise ParseException(f'Bad delta unit: {ctx.getText()}')
+ if 'time_delta_before_after' not in self.context:
+ raise ParseException(
+ f'Bad Before/After: {ctx.getText()}'
+ )
+
+ def exitDeltaTimeFraction(
+ self, ctx: dateparse_utilsParser.DeltaTimeFractionContext
+ ) -> None:
+ try:
+ txt = ctx.getText().lower()[:4]
+ if txt == 'quar':
+ self.context['time_delta_int'] = 15
+ self.context[
+ 'time_delta_unit'
+ ] = TimeUnit.MINUTES
+ elif txt == 'half':
+ self.context['time_delta_int'] = 30
+ self.context[
+ 'time_delta_unit'
+ ] = TimeUnit.MINUTES
else:
- raise ParseException(f"Bad delta unit: {ctx.getText()}")
- except:
- raise ParseException(f"Bad delta unit: {ctx.getText()}")
+ raise ParseException(f'Bad time fraction {ctx.getText()}')
+ except Exception:
+ raise ParseException(f'Bad time fraction {ctx.getText()}')
+
+ def exitDeltaBeforeAfter(
+ self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
+ ) -> None:
+ try:
+ txt = ctx.getText().lower()
+ except Exception:
+ raise ParseException(f'Bad delta before|after: {ctx.getText()}')
else:
- self.context["delta_unit"] = txt
+ self.context['delta_before_after'] = txt
- def enterDeltaBeforeAfter(
+ def exitDeltaTimeBeforeAfter(
self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
) -> None:
try:
txt = ctx.getText().lower()
- except:
- raise ParseException(f"Bad delta before|after: {ctx.getText()}")
+ except Exception:
+ raise ParseException(f'Bad delta before|after: {ctx.getText()}')
else:
- self.context["delta_before_after"] = txt
+ self.context['time_delta_before_after'] = txt
def exitNthWeekdayInMonthMaybeYearExpr(
self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext
...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 '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
+ 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:
+ year = self.context.get('year', self.today.year)
+ if 'month' not in self.context:
raise ParseException(
- f"Missing month expression: {ctx.getText()}"
+ f'Missing month expression: {ctx.getText()}'
)
- month = self.context["month"]
+ month = self.context['month']
- dow = self.context["dow"]
- del self.context["dow"]
- self.context["delta_unit"] = dow
+ 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
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
+ 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.context['year'] = year
+ self.context['month'] = month
+ self.context['day'] = 1
self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
- except:
+ except Exception:
raise ParseException(
- f"Invalid nthWeekday expression: {ctx.getText()}"
+ f'Invalid nthWeekday expression: {ctx.getText()}'
)
def exitFirstLastWeekdayInMonthMaybeYearExpr(
) -> None:
self.exitNthWeekdayInMonthMaybeYearExpr(ctx)
- def enterNth(self, ctx: dateparse_utilsParser.NthContext) -> None:
+ def exitNth(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()}")
+ i = self._get_int(ctx.getText())
+ except Exception:
+ raise ParseException(f'Bad nth expression: {ctx.getText()}')
else:
- self.context["nth"] = i
+ self.context['nth'] = i
- def enterFirstOrLast(
+ def exitFirstOrLast(
self, ctx: dateparse_utilsParser.FirstOrLastContext
) -> None:
try:
txt = ctx.getText()
- if txt == "first":
+ if txt == 'first':
txt = 1
- elif txt == "last":
+ elif txt == 'last':
txt = -1
else:
raise ParseException(
- f"Bad first|last expression: {ctx.getText()}"
+ f'Bad first|last expression: {ctx.getText()}'
)
- except:
- raise ParseException(f"Bad first|last expression: {ctx.getText()}")
+ except Exception:
+ raise ParseException(f'Bad first|last expression: {ctx.getText()}')
else:
- self.context["nth"] = txt
+ self.context['nth'] = txt
- def enterDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None:
+ def exitDayName(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")
+ except Exception:
+ raise ParseException('Bad day of week')
else:
- self.context["dow"] = dow
+ self.context['dow'] = dow
- def enterDayOfMonth(
+ def exitDayOfMonth(
self, ctx: dateparse_utilsParser.DayOfMonthContext
) -> None:
try:
- day = int(ctx.getText())
+ day = ctx.getText().lower()
+ if day[:3] == 'ide':
+ self.context['day'] = 'ide'
+ return
+ if day[:3] == 'non':
+ self.context['day'] = 'non'
+ return
+ if day[:3] == 'kal':
+ self.context['day'] = 1
+ return
+ day = self._get_int(day)
if day < 1 or day > 31:
raise ParseException(
- f"Bad dayOfMonth expression: {ctx.getText()}"
+ f'Bad dayOfMonth expression: {ctx.getText()}'
)
- except:
- raise ParseException(f"Bad dayOfMonth expression: {ctx.getText()}")
- self.context["day"] = day
+ except Exception:
+ raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}')
+ self.context['day'] = day
- def enterMonthName(
+ def exitMonthName(
self, ctx: dateparse_utilsParser.MonthNameContext
) -> None:
try:
month = ctx.getText()
- month = month.lower()[:3]
+ while month[0] == '/' or month[0] == '-':
+ month = month[1:]
+ month = month[:3].lower()
month = self.month_name_to_number.get(month, None)
if month is None:
raise ParseException(
- f"Bad monthName expression: {ctx.getText()}"
+ f'Bad monthName expression: {ctx.getText()}'
)
- except:
- raise ParseException(f"Bad monthName expression: {ctx.getText()}")
+ except Exception:
+ raise ParseException(f'Bad monthName expression: {ctx.getText()}')
else:
- self.context["month"] = month
+ self.context['month'] = month
- def enterMonthNumber(
+ def exitMonthNumber(
self, ctx: dateparse_utilsParser.MonthNumberContext
) -> None:
try:
- month = int(ctx.getText())
+ month = self._get_int(ctx.getText())
if month < 1 or month > 12:
raise ParseException(
- f"Bad monthNumber expression: {ctx.getText()}"
+ f'Bad monthNumber expression: {ctx.getText()}'
)
- except:
- raise ParseException(f"Bad monthNumber expression: {ctx.getText()}")
+ except Exception:
+ raise ParseException(
+ f'Bad monthNumber expression: {ctx.getText()}'
+ )
else:
- self.context["month"] = month
+ self.context['month'] = month
- def enterYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
+ def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
try:
- year = int(ctx.getText())
+ year = self._get_int(ctx.getText())
if year < 1:
- raise ParseException(f"Bad year expression: {ctx.getText()}")
- except:
- raise ParseException(f"Bad year expression: {ctx.getText()}")
+ raise ParseException(f'Bad year expression: {ctx.getText()}')
+ except Exception:
+ raise ParseException(f'Bad year expression: {ctx.getText()}')
else:
- self.context["year"] = year
+ self.context['year'] = year
- def enterSpecialDate(
- self, ctx: dateparse_utilsParser.SpecialDateContext
+ def exitSpecialDateMaybeYearExpr(
+ self, ctx: dateparse_utilsParser.SpecialDateMaybeYearExprContext
) -> None:
try:
- txt = ctx.getText().lower()
- except:
- raise ParseException(f"Bad specialDate expression: {ctx.getText()}")
+ special = ctx.specialDate().getText().lower()
+ self.context['special'] = special
+ except Exception:
+ raise ParseException(
+ f'Bad specialDate expression: {ctx.specialDate().getText()}'
+ )
+ try:
+ mod = ctx.thisNextLast()
+ if mod is not None:
+ if mod.THIS() is not None:
+ self.context['special_next_last'] = 'this'
+ elif mod.NEXT() is not None:
+ self.context['special_next_last'] = 'next'
+ elif mod.LAST() is not None:
+ self.context['special_next_last'] = 'last'
+ except Exception:
+ raise ParseException(
+ f'Bad specialDateNextLast expression: {ctx.getText()}'
+ )
+
+ def exitNFoosFromTodayAgoExpr(
+ self, ctx: dateparse_utilsParser.NFoosFromTodayAgoExprContext
+ ) -> None:
+ d = self.now_datetime
+ try:
+ count = self._get_int(ctx.unsignedInt().getText())
+ unit = ctx.deltaUnit().getText().lower()
+ ago_from_now = ctx.AGO_FROM_NOW().getText()
+ except Exception:
+ raise ParseException(
+ f'Bad NFoosFromTodayAgoExpr: {ctx.getText()}'
+ )
+
+ if "ago" in ago_from_now or "back" in ago_from_now:
+ count = -count
+
+ unit = self._figure_out_date_unit(unit)
+ d = n_timeunits_from_base(
+ count,
+ TimeUnit(unit),
+ d)
+ self.context['year'] = d.year
+ self.context['month'] = d.month
+ self.context['day'] = d.day
+
+ def exitDeltaRelativeToTodayExpr(
+ self, ctx: dateparse_utilsParser.DeltaRelativeToTodayExprContext
+ ) -> None:
+ d = self.now_datetime
+ try:
+ mod = ctx.thisNextLast()
+ if mod.LAST():
+ count = -1
+ elif mod.THIS():
+ count = +1
+ elif mod.NEXT():
+ count = +2
+ else:
+ raise ParseException(
+ f'Bad This/Next/Last modifier: {mod}'
+ )
+ unit = ctx.deltaUnit().getText().lower()
+ except Exception:
+ raise ParseException(
+ f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}'
+ )
+ unit = self._figure_out_date_unit(unit)
+ d = n_timeunits_from_base(
+ count,
+ TimeUnit(unit),
+ d)
+ self.context['year'] = d.year
+ self.context['month'] = d.month
+ self.context['day'] = d.day
+
+ def exitSpecialTimeExpr(
+ self, ctx: dateparse_utilsParser.SpecialTimeExprContext
+ ) -> None:
+ try:
+ txt = ctx.specialTime().getText().lower()
+ except Exception:
+ raise ParseException(
+ f'Bad special time expression: {ctx.getText()}'
+ )
else:
- self.context["special"] = txt
+ if txt == 'noon' or txt == 'midday':
+ self.context['hour'] = 12
+ self.context['minute'] = 0
+ self.context['seconds'] = 0
+ self.context['micros'] = 0
+ elif txt == 'midnight':
+ self.context['hour'] = 0
+ self.context['minute'] = 0
+ self.context['seconds'] = 0
+ self.context['micros'] = 0
+ else:
+ raise ParseException(f'Bad special time expression: {txt}')
+
+ try:
+ tz = ctx.tzExpr().getText()
+ self.context['tz'] = self._parse_tz(tz)
+ except Exception:
+ pass
+ def exitTwelveHourTimeExpr(
+ self, ctx: dateparse_utilsParser.TwelveHourTimeExprContext
+ ) -> None:
+ try:
+ hour = ctx.hour().getText()
+ while not hour[-1].isdigit():
+ hour = hour[:-1]
+ hour = self._get_int(hour)
+ except Exception:
+ raise ParseException(f'Bad hour: {ctx.hour().getText()}')
+ if hour <= 0 or hour > 12:
+ raise ParseException(f'Bad hour (out of range): {hour}')
+ try:
+ minute = self._get_int(ctx.minute().getText())
+ except Exception:
+ minute = 0
+ if minute < 0 or minute > 59:
+ raise ParseException(f'Bad minute (out of range): {minute}')
+ self.context['minute'] = minute
+
+ try:
+ seconds = self._get_int(ctx.second().getText())
+ except Exception:
+ seconds = 0
+ if seconds < 0 or seconds > 59:
+ raise ParseException(f'Bad second (out of range): {seconds}')
+ self.context['seconds'] = seconds
+
+ try:
+ micros = self._get_int(ctx.micros().getText())
+ except Exception:
+ micros = 0
+ if micros < 0 or micros > 1000000:
+ raise ParseException(f'Bad micros (out of range): {micros}')
+ self.context['micros'] = micros
+
+ try:
+ ampm = ctx.ampm().getText()
+ except Exception:
+ raise ParseException(f'Bad ampm: {ctx.ampm().getText()}')
+ if hour == 12:
+ hour = 0
+ if ampm[0] == 'p':
+ hour += 12
+ self.context['hour'] = hour
+
+ try:
+ tz = ctx.tzExpr().getText()
+ self.context['tz'] = self._parse_tz(tz)
+ except Exception:
+ pass
+
+ def exitTwentyFourHourTimeExpr(
+ self, ctx: dateparse_utilsParser.TwentyFourHourTimeExprContext
+ ) -> None:
+ try:
+ hour = ctx.hour().getText()
+ while not hour[-1].isdigit():
+ hour = hour[:-1]
+ hour = self._get_int(hour)
+ except Exception:
+ raise ParseException(f'Bad hour: {ctx.hour().getText()}')
+ if hour < 0 or hour > 23:
+ raise ParseException(f'Bad hour (out of range): {hour}')
+ self.context['hour'] = hour
+
+ try:
+ minute = self._get_int(ctx.minute().getText())
+ except Exception:
+ minute = 0
+ if minute < 0 or minute > 59:
+ raise ParseException(f'Bad minute (out of range): {ctx.getText()}')
+ self.context['minute'] = minute
+
+ try:
+ seconds = self._get_int(ctx.second().getText())
+ except Exception:
+ seconds = 0
+ if seconds < 0 or seconds > 59:
+ raise ParseException(f'Bad second (out of range): {ctx.getText()}')
+ self.context['seconds'] = seconds
+
+ try:
+ micros = self._get_int(ctx.micros().getText())
+ except Exception:
+ micros = 0
+ if micros < 0 or micros >= 1000000:
+ raise ParseException(f'Bad micros (out of range): {ctx.getText()}')
+ self.context['micros'] = micros
+
+ try:
+ tz = ctx.tzExpr().getText()
+ self.context['tz'] = self._parse_tz(tz)
+ except Exception:
+ pass
+
+
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))
+ try:
+ dt = parser.parse(line)
+ except Exception as e:
+ print("Unrecognized.")
+ else:
+ print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)'))
sys.exit(0)