#!/usr/bin/env python3 # type: ignore # pylint: disable=W0201 # pylint: disable=R0904 # © Copyright 2021-2022, Scott Gasch """ Parse dates / datetimes in a variety of formats. Some examples: | today | tomorrow | yesterday | 21:30 | 12:01am | 12:01pm | last Wednesday | this Wednesday | next Wed | this coming Tues | this past Mon | 4 days ago | 4 Mondays ago | 4 months ago | 3 days back | 13 weeks from now | 1 year from now | 4 weeks from now | 3 saturdays ago | 4 months from today | 5 years from yesterday | 6 weeks from tomorrow | april 15, 2005 | april 21 | 9:30am on last Wednesday | 2005/apr/15 | 2005 apr 15 | the 1st wednesday in may | the last sun of june | this easter | last xmas | Christmas, 1999 | next MLK day | Halloween, 2020 | 5 work days after independence day | 50 working days from last wed | 25 working days before xmas | today +1 week | sunday -3 weeks | 3 weeks before xmas, 1999 | 3 days before new years eve, 2000 | july 4th | the ides of march | the nones of april | the kalends of may | 4 sundays before veterans' day | xmas eve | this friday at 5pm | presidents day | memorial day, 1921 | thanksgiving | 2 sun in jun | easter -40 days | 2 days before last xmas at 3:14:15.92a | 3 weeks after xmas, 1995 at midday | 4 months before easter, 1992 at midnight | 5 months before halloween, 1995 at noon | 4 days before last wednesday | 44 months after today | 44 years before today | 44 weeks ago | 15 minutes to 3am | quarter past 4pm | half past 9 | 4 seconds to midnight | 4 seconds to midnight, tomorrow | 2021/apr/15T21:30:44.55 | 2021/apr/15 at 21:30:44.55 | 2021/4/15 at 21:30:44.55 | 2021/04/15 at 21:30:44.55Z | 2021/04/15 at 21:30:44.55EST | 13 days after last memorial day at 12 seconds before 4pm This code is used by other code in the pyutils library; for example, when using :file:`argparse_utils.py` to pass an argument of type datetime it allows the user to use free form English expressions. See the `unittest `_ for more examples and the `grammar `_ for more details. """ import datetime import functools import logging import re import sys from typing import Any, Callable, Dict, Optional import antlr4 # type: ignore import dateutil.easter import dateutil.tz import holidays # type: ignore import pytz from pyutils import bootstrap, decorator_utils from pyutils.datetimez.dateparse_utilsLexer import dateparse_utilsLexer # type: ignore from pyutils.datetimez.dateparse_utilsListener import ( dateparse_utilsListener, ) # type: ignore from pyutils.datetimez.dateparse_utilsParser import ( dateparse_utilsParser, ) # type: ignore from pyutils.datetimez.datetime_utils import ( TimeUnit, date_to_datetime, datetime_to_date, n_timeunits_from_base, ) from pyutils.security import acl 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: """ Args: message: parse error message description. """ super().__init__() 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): """A class to parse dates expressed in human language (English). Example usage:: d = DateParser() d.parse('next wednesday') dt = d.get_datetime() print(dt) Wednesday 2022/10/26 00:00:00.000000 Note that the interface is somewhat klunky here because this class is conforming to interfaces auto-generated by ANTLR as it parses the grammar. See also :meth:`pyutils.string_utils.to_date`. """ PARSE_TYPE_SINGLE_DATE_EXPR = 1 PARSE_TYPE_BASE_AND_OFFSET_EXPR = 2 PARSE_TYPE_SINGLE_TIME_EXPR = 3 PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR = 4 def __init__(self, *, override_now_for_test_purposes=None) -> None: """Construct a parser. Args: override_now_for_test_purposes: passing a value to override_now_for_test_purposes can be used to force this parser 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, } # 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, } # 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': TimeUnit.DAYS, 'wor': TimeUnit.WORKDAYS, 'wee': TimeUnit.WEEKS, 'mon': TimeUnit.MONTHS, 'yea': TimeUnit.YEARS, } self.override_now_for_test_purposes = override_now_for_test_purposes # Note: _reset defines several class fields. It is used both here # in the c'tor but also in between parse operations to restore the # class' state and allow it to be reused. # self._reset() def parse(self, date_string: str) -> Optional[datetime.datetime]: """ Parse a ~free form 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: :meth:`get_datetime`, :meth:`get_date` and :meth:`get_time`. Raises a `ParseException` with a helpful(?) message on parse error or confusion. This is the main entrypoint to this class for caller code. To get an idea of what expressions can be parsed, check out the unittest and the grammar. Args: date_string: the string to parse Returns: A datetime.datetime representing the parsed date/time or None on error. .. note:: 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. Example 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. """ date_string = date_string.strip() date_string = re.sub(r'\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.datetime def get_date(self) -> Optional[datetime.date]: """ Returns: The date part of the last :meth:`parse` operation again or None. """ return self.date def get_time(self) -> Optional[datetime.time]: """ Returns: The time part of the last :meth:`parse` operation again or None. """ return self.time def get_datetime(self, *, tz=None) -> Optional[datetime.datetime]: """Get the datetime of the last :meth:`parse` operation again ot None. Args: tz: the timezone to set on output times. By default we return timezone-naive datetime objects. Returns: the same datetime that :meth:`parse` last did, optionally overriding the timezone. Returns None of no calls to :meth:`parse` have yet been made. .. note:: 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. """ dt = self.datetime if dt is not None: 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] = {} self.timedelta = datetime.timedelta(seconds=0) self.saw_overt_year = False @staticmethod 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[:5] + eve name = name.replace('washi', 'presi') return name 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 in ('today', 'now'): return today 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 self.saw_overt_year = True elif next_last == 'last': year -= 1 self.saw_overt_year = True # 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(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 _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 and 'month' not 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 self.saw_overt_year = False else: self.saw_overt_year = True # 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=self.context['year'], month=self.context['month'], day=self.context['day'], ) @staticmethod def _parse_tz(txt: str) -> Any: if txt == 'Z': txt = 'UTC' # Try pytz try: tz1 = pytz.timezone(txt) if tz1 is not None: return tz1 except Exception: pass # Try dateutil try: tz2 = dateutil.tz.gettz(txt) if tz2 is not None: return tz2 except Exception: pass # Try constructing an offset in seconds try: txt_sign = txt[0] if txt_sign in ('-', '+'): sign = +1 if txt_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 @staticmethod def _get_int(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. Note: not part of the class' # public API(!!) -- 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 assert self.datetime is not None 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_date(self.context['special']) else: self.date = self._parse_normal_date() 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 '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 in ('before', 'until', 'til', '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'] 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 in ('before', 'until', 'til', '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: 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(f'Invalid Unit: "{unit}"') 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 = DateParser._get_int(n) unit = self._figure_out_date_unit(ctx.deltaUnit().getText().lower()) except Exception as e: raise ParseException(f'Invalid Delta +/-: {ctx.getText()}') from e else: self.context['delta_int'] = n self.context['delta_unit'] = unit def exitNextLastUnit(self, ctx: dateparse_utilsParser.DeltaUnitContext) -> None: try: unit = self._figure_out_date_unit(ctx.getText().lower()) except Exception as e: raise ParseException(f'Bad delta unit: {ctx.getText()}') from e else: self.context['delta_unit'] = unit def exitDeltaNextLast( self, ctx: dateparse_utilsParser.DeltaNextLastContext ) -> None: try: txt = ctx.getText().lower() except Exception as e: raise ParseException(f'Bad next/last: {ctx.getText()}') from e 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 self.saw_overt_year = True 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 self.saw_overt_year = True 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 as e: raise ParseException(f'Bad delta unit: {ctx.getText()}') from e 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 time fraction {ctx.getText()}') except Exception as e: raise ParseException(f'Bad time fraction {ctx.getText()}') from e def exitDeltaBeforeAfter( self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext ) -> None: try: txt = ctx.getText().lower() except Exception as e: raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e else: self.context['delta_before_after'] = txt def exitDeltaTimeBeforeAfter( self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext ) -> None: try: txt = ctx.getText().lower() except Exception as e: raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e else: self.context['time_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', self.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 last day of # the previous month 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) # The delta adjustment code can handle the case where # the last day of the month is the day we're looking # for already. else: 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.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR except Exception as e: raise ParseException( f'Invalid nthWeekday expression: {ctx.getText()}' ) from e def exitFirstLastWeekdayInMonthMaybeYearExpr( self, ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext, ) -> None: self.exitNthWeekdayInMonthMaybeYearExpr(ctx) def exitNth(self, ctx: dateparse_utilsParser.NthContext) -> None: try: i = DateParser._get_int(ctx.getText()) except Exception as e: raise ParseException(f'Bad nth expression: {ctx.getText()}') from e else: self.context['nth'] = i def exitFirstOrLast(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 Exception as e: raise ParseException(f'Bad first|last expression: {ctx.getText()}') from e else: self.context['nth'] = txt def exitDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None: try: dow = ctx.getText().lower()[:3] dow = self.day_name_to_number.get(dow, None) except Exception as e: raise ParseException('Bad day of week') from e else: self.context['dow'] = dow def exitDayOfMonth(self, ctx: dateparse_utilsParser.DayOfMonthContext) -> None: try: 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 = DateParser._get_int(day) if day < 1 or day > 31: raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') except Exception as e: raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') from e self.context['day'] = day def exitMonthName(self, ctx: dateparse_utilsParser.MonthNameContext) -> None: try: month = ctx.getText() 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()}') except Exception as e: raise ParseException(f'Bad monthName expression: {ctx.getText()}') from e else: self.context['month'] = month def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None: try: month = DateParser._get_int(ctx.getText()) if month < 1 or month > 12: raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') except Exception as e: raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') from e else: self.context['month'] = month def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None: try: year = DateParser._get_int(ctx.getText()) if year < 1: raise ParseException(f'Bad year expression: {ctx.getText()}') except Exception as e: raise ParseException(f'Bad year expression: {ctx.getText()}') from e else: self.saw_overt_year = True self.context['year'] = year def exitSpecialDateMaybeYearExpr( self, ctx: dateparse_utilsParser.SpecialDateMaybeYearExprContext ) -> None: try: special = ctx.specialDate().getText().lower() self.context['special'] = special except Exception as e: raise ParseException( f'Bad specialDate expression: {ctx.specialDate().getText()}' ) from e 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 as e: raise ParseException( f'Bad specialDateNextLast expression: {ctx.getText()}' ) from e def exitNFoosFromTodayAgoExpr( self, ctx: dateparse_utilsParser.NFoosFromTodayAgoExprContext ) -> None: d = self.now_datetime try: count = DateParser._get_int(ctx.unsignedInt().getText()) unit = ctx.deltaUnit().getText().lower() ago_from_now = ctx.AGO_FROM_NOW().getText() except Exception as e: raise ParseException(f'Bad NFoosFromTodayAgoExpr: {ctx.getText()}') from e 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: # When someone says "next week" they mean a week from now. # Likewise next month or last year. These expressions are now # +/- delta. # # But when someone says "this Friday" they mean "this coming # Friday". It would be weird to say "this Friday" if today # was already Friday but I'm parsing it to mean: the next day # that is a Friday. So when you say "next Friday" you mean # the Friday after this coming Friday, or 2 Fridays from now. # # This set handles this weirdness. weekdays = set( [ TimeUnit.MONDAYS, TimeUnit.TUESDAYS, TimeUnit.WEDNESDAYS, TimeUnit.THURSDAYS, TimeUnit.FRIDAYS, TimeUnit.SATURDAYS, TimeUnit.SUNDAYS, ] ) d = self.now_datetime try: mod = ctx.thisNextLast() unit = ctx.deltaUnit().getText().lower() unit = self._figure_out_date_unit(unit) if mod.LAST(): count = -1 elif mod.THIS(): if unit in weekdays: count = +1 else: count = 0 elif mod.NEXT(): if unit in weekdays: count = +2 else: count = +1 else: raise ParseException(f'Bad This/Next/Last modifier: {mod}') except Exception as e: raise ParseException( f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}' ) from e 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 as e: raise ParseException(f'Bad special time expression: {ctx.getText()}') from e else: if txt in ('noon', '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'] = DateParser._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 = DateParser._get_int(hour) except Exception as e: raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e if hour <= 0 or hour > 12: raise ParseException(f'Bad hour (out of range): {hour}') try: minute = DateParser._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 = DateParser._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 = DateParser._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 as e: raise ParseException(f'Bad ampm: {ctx.ampm().getText()}') from e if hour == 12: hour = 0 if ampm[0] == 'p': hour += 12 self.context['hour'] = hour try: tz = ctx.tzExpr().getText() self.context['tz'] = DateParser._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 = DateParser._get_int(hour) except Exception as e: raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e if hour < 0 or hour > 23: raise ParseException(f'Bad hour (out of range): {hour}') self.context['hour'] = hour try: minute = DateParser._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 = DateParser._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 = DateParser._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'] = DateParser._parse_tz(tz) except Exception: pass @bootstrap.initialize def main() -> None: parser = DateParser() for line in sys.stdin: line = line.strip() line = re.sub(r"#.*$", "", line) if re.match(r"^ *$", line) is not None: continue try: dt = parser.parse(line) except Exception as e: logger.exception(e) print("Unrecognized.") else: assert dt is not None print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)')) sys.exit(0) if __name__ == "__main__": main()