#!/usr/bin/env python3 """ Parse dates in a variety of formats. """ import datetime import functools import holidays # type: ignore import logging import re import sys 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 PARSE_TYPE_SINGLE_TIME_EXPR = 3 PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR = 4 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, } # 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 self._reset() 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(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]: """Return the date part or None.""" return self.date 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] = {} 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 == 'today' or name == '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: 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'], ) 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_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 == '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'] 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: 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'] = 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: 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 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: 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 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_before_after'] = txt def exitDeltaTimeBeforeAfter( 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['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 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 Exception: raise ParseException(f'Invalid nthWeekday expression: {ctx.getText()}') def exitFirstLastWeekdayInMonthMaybeYearExpr( self, ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext, ) -> None: self.exitNthWeekdayInMonthMaybeYearExpr(ctx) def exitNth(self, ctx: dateparse_utilsParser.NthContext) -> None: try: i = self._get_int(ctx.getText()) except Exception: raise ParseException(f'Bad nth expression: {ctx.getText()}') 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: raise ParseException(f'Bad first|last expression: {ctx.getText()}') 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: raise ParseException('Bad day of week') 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 = self._get_int(day) if day < 1 or day > 31: raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') except Exception: raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') 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: raise ParseException(f'Bad monthName expression: {ctx.getText()}') else: self.context['month'] = month def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None: try: month = self._get_int(ctx.getText()) if month < 1 or month > 12: raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') except Exception: raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') else: self.context['month'] = month def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None: try: year = self._get_int(ctx.getText()) if year < 1: raise ParseException(f'Bad year expression: {ctx.getText()}') except Exception: raise ParseException(f'Bad year expression: {ctx.getText()}') 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: 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: # 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: raise ParseException(f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}') 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: 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 @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: print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)')) sys.exit(0) if __name__ == "__main__": main()