Since this thing is on the innerwebs I suppose it should have a
[python_utils.git] / dateparse / dateparse_utils.py
index 026a5137a07a6027924080f27ec03d41d0913016..bd0d491f17e55fc4eef1894cfbe8a544b88787f2 100755 (executable)
@@ -1,8 +1,14 @@
 #!/usr/bin/env python3
+# type: ignore
+# pylint: disable=W0201
+# pylint: disable=R0904
+
+# © Copyright 2021-2022, Scott Gasch
+
+"""Parse dates in a variety of formats."""
 
 import datetime
 import functools
-import holidays  # type: ignore
 import logging
 import re
 import sys
@@ -11,18 +17,21 @@ from typing import Any, Callable, Dict, Optional
 import antlr4  # type: ignore
 import dateutil.easter
 import dateutil.tz
+import holidays  # type: ignore
 import pytz
 
 import acl
 import bootstrap
-from datetime_utils import (
-    TimeUnit, n_timeunits_from_base, datetime_to_date, date_to_datetime
-)
+import decorator_utils
 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
-
+from datetime_utils import (
+    TimeUnit,
+    date_to_datetime,
+    datetime_to_date,
+    n_timeunits_from_base,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -30,51 +39,44 @@ 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]
+        slf = args[0]
         ctx = args[1]
         depth = ctx.depth()
         logger.debug(
-            '  ' * (depth-1) +
-            f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
+            '  ' * (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)}'
-            )
+            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:
+        super().__init__()
         self.message = message
 
 
 class RaisingErrorListener(antlr4.DiagnosticErrorListener):
     """An error listener that raises ParseExceptions."""
-    def syntaxError(
-            self, recognizer, offendingSymbol, line, column, msg, e
-    ):
+
+    def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
         raise ParseException(msg)
 
-    def reportAmbiguity(
-            self, recognizer, dfa, startIndex, stopIndex, exact,
-            ambigAlts, configs
-    ):
+    def reportAmbiguity(self, recognizer, dfa, startIndex, stopIndex, exact, ambigAlts, configs):
         pass
 
     def reportAttemptingFullContext(
-            self, recognizer, dfa, startIndex, stopIndex, conflictingAlts,
-            configs
+        self, recognizer, dfa, startIndex, stopIndex, conflictingAlts, configs
     ):
         pass
 
-    def reportContextSensitivity(
-            self, recognizer, dfa, startIndex, stopIndex, prediction, configs
-    ):
+    def reportContextSensitivity(self, recognizer, dfa, startIndex, stopIndex, prediction, configs):
         pass
 
 
@@ -85,25 +87,20 @@ class RaisingErrorListener(antlr4.DiagnosticErrorListener):
             'enter*',
             'exit*',
         ],
-        denied_patterns=[
-            'enterEveryRule',
-            'exitEveryRule'
-        ],
+        denied_patterns=['enterEveryRule', 'exitEveryRule'],
         order_to_check_allow_deny=acl.Order.DENY_ALLOW,
-        default_answer=False
-    )
+        default_answer=False,
+    ),
 )
 class DateParser(dateparse_utilsListener):
+    """A class to parse dates expressed in human language."""
+
     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:
+    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.
@@ -137,7 +134,7 @@ class DateParser(dateparse_utilsListener):
             9: 30,
             10: 31,
             11: 30,
-            12: 31
+            12: 31,
         }
 
         # N.B. day number is also synched with datetime_utils.TimeUnit values
@@ -167,6 +164,11 @@ class DateParser(dateparse_utilsListener):
             '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]:
@@ -193,7 +195,7 @@ class DateParser(dateparse_utilsListener):
         This is the main entrypoint to this class for caller code.
         """
         date_string = date_string.strip()
-        date_string = re.sub('\s+', ' ', date_string)
+        date_string = re.sub(r'\s+', ' ', date_string)
         self._reset()
         listener = RaisingErrorListener()
         input_stream = antlr4.InputStream(date_string)
@@ -228,8 +230,9 @@ class DateParser(dateparse_utilsListener):
         to timezone naive (i.e. tzinfo = None).
         """
         dt = self.datetime
-        if tz is not None:
-            dt = dt.replace(tzinfo=None).astimezone(tz=tz)
+        if dt is not None:
+            if tz is not None:
+                dt = dt.replace(tzinfo=None).astimezone(tz=tz)
         return dt
 
     # -- helpers --
@@ -241,14 +244,13 @@ class DateParser(dateparse_utilsListener):
             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.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:
@@ -269,16 +271,16 @@ class DateParser(dateparse_utilsListener):
             return TimeUnit.MONTHS
         txt = orig.lower()[:3]
         if txt in self.day_name_to_number:
-            return(TimeUnit(self.day_name_to_number[txt]))
+            return TimeUnit(self.day_name_to_number[txt])
         elif txt in self.delta_unit_to_constant:
-            return(TimeUnit(self.delta_unit_to_constant[txt]))
+            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])
+            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]:
@@ -290,7 +292,7 @@ class DateParser(dateparse_utilsListener):
         name = DateParser._normalize_special_day_name(self.context['special'])
 
         # Yesterday, today, tomorrow -- ignore any next/last
-        if name == 'today' or name == 'now':
+        if name in ('today', 'now'):
             return today
         if name == 'yeste':
             return today + datetime.timedelta(days=-1)
@@ -300,8 +302,10 @@ class DateParser(dateparse_utilsListener):
         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':
@@ -309,13 +313,9 @@ class DateParser(dateparse_utilsListener):
         elif name == 'hallo':
             return datetime.date(year=year, month=10, day=31)
 
-        for holiday_date, holiday_name in sorted(
-            holidays.US(years=year).items()
-        ):
+        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
-                )
+                holiday_name = DateParser._normalize_special_day_name(holiday_name)
                 if name == holiday_name:
                     return holiday_date
         if name == 'chriseve':
@@ -360,12 +360,12 @@ class DateParser(dateparse_utilsListener):
             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'
-        ):
+        if self.context['day'] == 'ide' or self.context['day'] == 'non':
             self.context['day'] = self._resolve_ides_nones(
                 self.context['day'], self.context['month']
             )
@@ -376,31 +376,32 @@ class DateParser(dateparse_utilsListener):
             day=self.context['day'],
         )
 
-    def _parse_tz(self, txt: str) -> Any:
+    @staticmethod
+    def _parse_tz(txt: str) -> Any:
         if txt == 'Z':
             txt = 'UTC'
 
         # Try pytz
         try:
-            tz = pytz.timezone(txt)
-            if tz is not None:
-                return tz
+            tz1 = pytz.timezone(txt)
+            if tz1 is not None:
+                return tz1
         except Exception:
             pass
 
         # Try dateutil
         try:
-            tz = dateutil.tz.gettz(txt)
-            if tz is not None:
-                return tz
+            tz2 = dateutil.tz.gettz(txt)
+            if tz2 is not None:
+                return tz2
         except Exception:
             pass
 
         # Try constructing an offset in seconds
         try:
-            sign = txt[0]
-            if sign == '-' or sign == '+':
-                sign = +1 if sign == '+' else -1
+            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)
@@ -410,14 +411,16 @@ class DateParser(dateparse_utilsListener):
             pass
         return None
 
-    def _get_int(self, txt: str) -> int:
+    @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 --
+    # -- overridden methods invoked by parse walk.  Note: not part of the class'
+    # public API(!!) --
 
     def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
         pass
@@ -441,19 +444,26 @@ class DateParser(dateparse_utilsListener):
         micros = self.time.microsecond
 
         self.datetime = datetime.datetime(
-            year, month, day, hour, minute, second, micros,
-            tzinfo=self.time.tzinfo
+            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
+            self.datetime.tzinfo,
         )
 
     def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
@@ -493,23 +503,14 @@ class DateParser(dateparse_utilsListener):
         # 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'
-            ):
+            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)
-        )
+        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:
@@ -538,12 +539,7 @@ class DateParser(dateparse_utilsListener):
         # 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'
-            ):
+            if before_after in ('before', 'until', 'til', 'to'):
                 count = -count
 
         # What are we counting units of... assume minutes.
@@ -558,63 +554,49 @@ class DateParser(dateparse_utilsListener):
             elif unit == TimeUnit.HOURS:
                 self.timedelta = datetime.timedelta(hours=count)
             else:
-                raise ParseException()
+                raise ParseException(f'Invalid Unit: "{unit}"')
 
-    def exitDeltaPlusMinusExpr(
-        self, ctx: dateparse_utilsParser.DeltaPlusMinusExprContext
-    ) -> None:
+    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()}'
-                )
+                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()}')
+            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:
+    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()}')
+        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:
+    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.'
-            )
+        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()}')
 
@@ -622,58 +604,42 @@ class DateParser(dateparse_utilsListener):
         self, ctx: dateparse_utilsParser.CountUnitsBeforeAfterTimeExprContext
     ) -> None:
         if 'nth' not in self.context:
-            raise ParseException(
-                f'Bad count expression: {ctx.getText()}'
-            )
+            raise ParseException(f'Bad count expression: {ctx.getText()}')
         try:
-            unit = self._figure_out_time_unit(
-                ctx.deltaTimeUnit().getText().lower()
-            )
+            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()}')
+        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()}'
-            )
+            raise ParseException(f'Bad Before/After: {ctx.getText()}')
 
-    def exitDeltaTimeFraction(
-            self, ctx: dateparse_utilsParser.DeltaTimeFractionContext
-    ) -> None:
+    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
+                self.context['time_delta_unit'] = TimeUnit.MINUTES
             elif txt == 'half':
                 self.context['time_delta_int'] = 30
-                self.context[
-                    'time_delta_unit'
-                ] = TimeUnit.MINUTES
+                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()}')
+        except Exception as e:
+            raise ParseException(f'Bad time fraction {ctx.getText()}') from e
 
-    def exitDeltaBeforeAfter(
-        self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
-    ) -> None:
+    def exitDeltaBeforeAfter(self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext) -> None:
         try:
             txt = ctx.getText().lower()
-        except Exception:
-            raise ParseException(f'Bad delta before|after: {ctx.getText()}')
+        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:
+    def exitDeltaTimeBeforeAfter(self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext) -> None:
         try:
             txt = ctx.getText().lower()
-        except Exception:
-            raise ParseException(f'Bad delta before|after: {ctx.getText()}')
+        except Exception as e:
+            raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e
         else:
             self.context['time_delta_before_after'] = txt
 
@@ -699,9 +665,7 @@ class DateParser(dateparse_utilsListener):
 
             year = self.context.get('year', self.today.year)
             if 'month' not in self.context:
-                raise ParseException(
-                    f'Missing month expression: {ctx.getText()}'
-                )
+                raise ParseException(f'Missing month expression: {ctx.getText()}')
             month = self.context['month']
 
             dow = self.context['dow']
@@ -732,10 +696,8 @@ class DateParser(dateparse_utilsListener):
                 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()}'
-            )
+        except Exception as e:
+            raise ParseException(f'Invalid nthWeekday expression: {ctx.getText()}') from e
 
     def exitFirstLastWeekdayInMonthMaybeYearExpr(
         self,
@@ -745,15 +707,13 @@ class DateParser(dateparse_utilsListener):
 
     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()}')
+            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:
+    def exitFirstOrLast(self, ctx: dateparse_utilsParser.FirstOrLastContext) -> None:
         try:
             txt = ctx.getText()
             if txt == 'first':
@@ -761,11 +721,9 @@ class DateParser(dateparse_utilsListener):
             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()}')
+                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
 
@@ -773,14 +731,12 @@ class DateParser(dateparse_utilsListener):
         try:
             dow = ctx.getText().lower()[:3]
             dow = self.day_name_to_number.get(dow, None)
-        except Exception:
-            raise ParseException('Bad day of week')
+        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:
+    def exitDayOfMonth(self, ctx: dateparse_utilsParser.DayOfMonthContext) -> None:
         try:
             day = ctx.getText().lower()
             if day[:3] == 'ide':
@@ -792,18 +748,14 @@ class DateParser(dateparse_utilsListener):
             if day[:3] == 'kal':
                 self.context['day'] = 1
                 return
-            day = self._get_int(day)
+            day = DateParser._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()}')
+                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:
+    def exitMonthName(self, ctx: dateparse_utilsParser.MonthNameContext) -> None:
         try:
             month = ctx.getText()
             while month[0] == '/' or month[0] == '-':
@@ -811,38 +763,31 @@ class DateParser(dateparse_utilsListener):
             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()}')
+                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:
+    def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None:
         try:
-            month = self._get_int(ctx.getText())
+            month = DateParser._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()}'
-            )
+                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 = self._get_int(ctx.getText())
+            year = DateParser._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()}')
+        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(
@@ -851,10 +796,10 @@ class DateParser(dateparse_utilsListener):
         try:
             special = ctx.specialDate().getText().lower()
             self.context['special'] = special
-        except Exception:
+        except Exception as e:
             raise ParseException(
                 f'Bad specialDate expression: {ctx.specialDate().getText()}'
-            )
+            ) from e
         try:
             mod = ctx.thisNextLast()
             if mod is not None:
@@ -864,32 +809,25 @@ class DateParser(dateparse_utilsListener):
                     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()}'
-            )
+        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 = self._get_int(ctx.unsignedInt().getText())
+            count = DateParser._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()}'
-            )
+        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)
+        d = n_timeunits_from_base(count, TimeUnit(unit), d)
         self.context['year'] = d.year
         self.context['month'] = d.month
         self.context['day'] = d.day
@@ -897,44 +835,61 @@ class DateParser(dateparse_utilsListener):
     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():
-                count = +1
+                if unit in weekdays:
+                    count = +1
+                else:
+                    count = 0
             elif mod.NEXT():
-                count = +2
+                if unit in weekdays:
+                    count = +2
+                else:
+                    count = +1
             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)
+                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:
+    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()}'
-            )
+        except Exception as e:
+            raise ParseException(f'Bad special time expression: {ctx.getText()}') from e
         else:
-            if txt == 'noon' or txt == 'midday':
+            if txt in ('noon', 'midday'):
                 self.context['hour'] = 12
                 self.context['minute'] = 0
                 self.context['seconds'] = 0
@@ -949,25 +904,23 @@ class DateParser(dateparse_utilsListener):
 
         try:
             tz = ctx.tzExpr().getText()
-            self.context['tz'] = self._parse_tz(tz)
+            self.context['tz'] = DateParser._parse_tz(tz)
         except Exception:
             pass
 
-    def exitTwelveHourTimeExpr(
-        self, ctx: dateparse_utilsParser.TwelveHourTimeExprContext
-    ) -> None:
+    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()}')
+            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 = self._get_int(ctx.minute().getText())
+            minute = DateParser._get_int(ctx.minute().getText())
         except Exception:
             minute = 0
         if minute < 0 or minute > 59:
@@ -975,7 +928,7 @@ class DateParser(dateparse_utilsListener):
         self.context['minute'] = minute
 
         try:
-            seconds = self._get_int(ctx.second().getText())
+            seconds = DateParser._get_int(ctx.second().getText())
         except Exception:
             seconds = 0
         if seconds < 0 or seconds > 59:
@@ -983,7 +936,7 @@ class DateParser(dateparse_utilsListener):
         self.context['seconds'] = seconds
 
         try:
-            micros = self._get_int(ctx.micros().getText())
+            micros = DateParser._get_int(ctx.micros().getText())
         except Exception:
             micros = 0
         if micros < 0 or micros > 1000000:
@@ -992,8 +945,8 @@ class DateParser(dateparse_utilsListener):
 
         try:
             ampm = ctx.ampm().getText()
-        except Exception:
-            raise ParseException(f'Bad 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':
@@ -1002,7 +955,7 @@ class DateParser(dateparse_utilsListener):
 
         try:
             tz = ctx.tzExpr().getText()
-            self.context['tz'] = self._parse_tz(tz)
+            self.context['tz'] = DateParser._parse_tz(tz)
         except Exception:
             pass
 
@@ -1013,15 +966,15 @@ class DateParser(dateparse_utilsListener):
             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()}')
+            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 = self._get_int(ctx.minute().getText())
+            minute = DateParser._get_int(ctx.minute().getText())
         except Exception:
             minute = 0
         if minute < 0 or minute > 59:
@@ -1029,7 +982,7 @@ class DateParser(dateparse_utilsListener):
         self.context['minute'] = minute
 
         try:
-            seconds = self._get_int(ctx.second().getText())
+            seconds = DateParser._get_int(ctx.second().getText())
         except Exception:
             seconds = 0
         if seconds < 0 or seconds > 59:
@@ -1037,7 +990,7 @@ class DateParser(dateparse_utilsListener):
         self.context['seconds'] = seconds
 
         try:
-            micros = self._get_int(ctx.micros().getText())
+            micros = DateParser._get_int(ctx.micros().getText())
         except Exception:
             micros = 0
         if micros < 0 or micros >= 1000000:
@@ -1046,7 +999,7 @@ class DateParser(dateparse_utilsListener):
 
         try:
             tz = ctx.tzExpr().getText()
-            self.context['tz'] = self._parse_tz(tz)
+            self.context['tz'] = DateParser._parse_tz(tz)
         except Exception:
             pass
 
@@ -1062,8 +1015,10 @@ def main() -> None:
         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)