More documentation improvements.
[pyutils.git] / src / pyutils / datetimez / dateparse_utils.py
1 #!/usr/bin/env python3
2 # type: ignore
3 # pylint: disable=W0201
4 # pylint: disable=R0904
5
6 # © Copyright 2021-2022, Scott Gasch
7
8 """
9 Parse dates / datetimes in a variety of formats.  Some examples:
10
11     |    today
12     |    tomorrow
13     |    yesterday
14     |    21:30
15     |    12:01am
16     |    12:01pm
17     |    last Wednesday
18     |    this Wednesday
19     |    next Wed
20     |    this coming Tues
21     |    this past Mon
22     |    4 days ago
23     |    4 Mondays ago
24     |    4 months ago
25     |    3 days back
26     |    13 weeks from now
27     |    1 year from now
28     |    4 weeks from now
29     |    3 saturdays ago
30     |    4 months from today
31     |    5 years from yesterday
32     |    6 weeks from tomorrow
33     |    april 15, 2005
34     |    april 21
35     |    9:30am on last Wednesday
36     |    2005/apr/15
37     |    2005 apr 15
38     |    the 1st wednesday in may
39     |    the last sun of june
40     |    this easter
41     |    last xmas
42     |    Christmas, 1999
43     |    next MLK day
44     |    Halloween, 2020
45     |    5 work days after independence day
46     |    50 working days from last wed
47     |    25 working days before xmas
48     |    today +1 week
49     |    sunday -3 weeks
50     |    3 weeks before xmas, 1999
51     |    3 days before new years eve, 2000
52     |    july 4th
53     |    the ides of march
54     |    the nones of april
55     |    the kalends of may
56     |    4 sundays before veterans' day
57     |    xmas eve
58     |    this friday at 5pm
59     |    presidents day
60     |    memorial day, 1921
61     |    thanksgiving
62     |    2 sun in jun
63     |    easter -40 days
64     |    2 days before last xmas at 3:14:15.92a
65     |    3 weeks after xmas, 1995 at midday
66     |    4 months before easter, 1992 at midnight
67     |    5 months before halloween, 1995 at noon
68     |    4 days before last wednesday
69     |    44 months after today
70     |    44 years before today
71     |    44 weeks ago
72     |    15 minutes to 3am
73     |    quarter past 4pm
74     |    half past 9
75     |    4 seconds to midnight
76     |    4 seconds to midnight, tomorrow
77     |    2021/apr/15T21:30:44.55
78     |    2021/apr/15 at 21:30:44.55
79     |    2021/4/15 at 21:30:44.55
80     |    2021/04/15 at 21:30:44.55Z
81     |    2021/04/15 at 21:30:44.55EST
82     |    13 days after last memorial day at 12 seconds before 4pm
83
84 This code is used by other code in the pyutils library such as
85 :meth:`pyutils.argparse_utils.valid_datetime`,
86 :meth:`pyutils.argparse_utils.valid_date`,
87 :meth:`pyutils.string_utils.to_datetime`
88 and
89 :meth:`pyutils.string_utils.to_date`.  This means any of these are
90 also able to accept and recognize this larger set of date expressions.
91
92 See the `unittest <https://wannabe.guru.org/gitweb/?p=pyutils.git;a=blob_plain;f=tests/datetimez/dateparse_utils_test.py;h=93c7b96e4c19af217fbafcf1ed5dbde13ec599c5;hb=HEAD>`_ for more examples and the `grammar <https://wannabe.guru.org/gitweb/?p=pyutils.git;a=blob_plain;f=src/pyutils/datetimez/dateparse_utils.g4;hb=HEAD>`_ for more details.
93 """
94
95 import datetime
96 import functools
97 import logging
98 import re
99 import sys
100 from typing import Any, Callable, Dict, Optional
101
102 import antlr4  # type: ignore
103 import dateutil.easter
104 import dateutil.tz
105 import holidays  # type: ignore
106 import pytz
107
108 from pyutils import bootstrap, decorator_utils
109 from pyutils.datetimez.dateparse_utilsLexer import dateparse_utilsLexer  # type: ignore
110 from pyutils.datetimez.dateparse_utilsListener import (
111     dateparse_utilsListener,
112 )  # type: ignore
113 from pyutils.datetimez.dateparse_utilsParser import (
114     dateparse_utilsParser,
115 )  # type: ignore
116 from pyutils.datetimez.datetime_utils import (
117     TimeUnit,
118     date_to_datetime,
119     datetime_to_date,
120     n_timeunits_from_base,
121 )
122 from pyutils.security import acl
123
124 logger = logging.getLogger(__name__)
125
126
127 def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
128     @functools.wraps(enter_or_exit_f)
129     def debug_parse_wrapper(*args, **kwargs):
130         # slf = args[0]
131         ctx = args[1]
132         depth = ctx.depth()
133         logger.debug(
134             '  ' * (depth - 1)
135             + f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
136         )
137         for c in ctx.getChildren():
138             logger.debug('  ' * (depth - 1) + f'{c} {type(c)}')
139         retval = enter_or_exit_f(*args, **kwargs)
140         return retval
141
142     return debug_parse_wrapper
143
144
145 class ParseException(Exception):
146     """An exception thrown during parsing because of unrecognized input."""
147
148     def __init__(self, message: str) -> None:
149         """
150         Args:
151             message: parse error message description.
152         """
153         super().__init__()
154         self.message = message
155
156
157 class RaisingErrorListener(antlr4.DiagnosticErrorListener):
158     """An error listener that raises ParseExceptions."""
159
160     def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
161         raise ParseException(msg)
162
163     def reportAmbiguity(
164         self, recognizer, dfa, startIndex, stopIndex, exact, ambigAlts, configs
165     ):
166         pass
167
168     def reportAttemptingFullContext(
169         self, recognizer, dfa, startIndex, stopIndex, conflictingAlts, configs
170     ):
171         pass
172
173     def reportContextSensitivity(
174         self, recognizer, dfa, startIndex, stopIndex, prediction, configs
175     ):
176         pass
177
178
179 @decorator_utils.decorate_matching_methods_with(
180     debug_parse,
181     acl=acl.StringWildcardBasedACL(
182         allowed_patterns=[
183             'enter*',
184             'exit*',
185         ],
186         denied_patterns=['enterEveryRule', 'exitEveryRule'],
187         order_to_check_allow_deny=acl.Order.DENY_ALLOW,
188         default_answer=False,
189     ),
190 )
191 class DateParser(dateparse_utilsListener):
192     """A class to parse dates expressed in human language (English).
193     Example usage::
194
195         d = DateParser()
196         d.parse('next wednesday')
197         dt = d.get_datetime()
198         print(dt)
199         Wednesday 2022/10/26 00:00:00.000000
200
201     Note that the interface is somewhat klunky here because this class is
202     conforming to interfaces auto-generated by ANTLR as it parses the grammar.
203     See also :meth:`pyutils.string_utils.to_date`.
204
205     """
206
207     PARSE_TYPE_SINGLE_DATE_EXPR = 1
208     PARSE_TYPE_BASE_AND_OFFSET_EXPR = 2
209     PARSE_TYPE_SINGLE_TIME_EXPR = 3
210     PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR = 4
211
212     def __init__(self, *, override_now_for_test_purposes=None) -> None:
213         """Construct a parser.
214
215         Args:
216             override_now_for_test_purposes: passing a value to
217                 override_now_for_test_purposes can be used to force
218                 this parser instance to use a custom date/time for its
219                 idea of "now" so that the code can be more easily
220                 unittested.  Leave as None for real use cases.
221         """
222         self.month_name_to_number = {
223             'jan': 1,
224             'feb': 2,
225             'mar': 3,
226             'apr': 4,
227             'may': 5,
228             'jun': 6,
229             'jul': 7,
230             'aug': 8,
231             'sep': 9,
232             'oct': 10,
233             'nov': 11,
234             'dec': 12,
235         }
236
237         # Used only for ides/nones.  Month length on a non-leap year.
238         self.typical_days_per_month = {
239             1: 31,
240             2: 28,
241             3: 31,
242             4: 30,
243             5: 31,
244             6: 30,
245             7: 31,
246             8: 31,
247             9: 30,
248             10: 31,
249             11: 30,
250             12: 31,
251         }
252
253         # N.B. day number is also synched with datetime_utils.TimeUnit values
254         # which allows expressions like "3 wednesdays from now" to work.
255         self.day_name_to_number = {
256             'mon': 0,
257             'tue': 1,
258             'wed': 2,
259             'thu': 3,
260             'fri': 4,
261             'sat': 5,
262             'sun': 6,
263         }
264
265         # These TimeUnits are defined in datetime_utils and are used as params
266         # to datetime_utils.n_timeunits_from_base.
267         self.time_delta_unit_to_constant = {
268             'hou': TimeUnit.HOURS,
269             'min': TimeUnit.MINUTES,
270             'sec': TimeUnit.SECONDS,
271         }
272         self.delta_unit_to_constant = {
273             'day': TimeUnit.DAYS,
274             'wor': TimeUnit.WORKDAYS,
275             'wee': TimeUnit.WEEKS,
276             'mon': TimeUnit.MONTHS,
277             'yea': TimeUnit.YEARS,
278         }
279         self.override_now_for_test_purposes = override_now_for_test_purposes
280
281         # Note: _reset defines several class fields.  It is used both here
282         # in the c'tor but also in between parse operations to restore the
283         # class' state and allow it to be reused.
284         #
285         self._reset()
286
287     def parse(self, date_string: str) -> Optional[datetime.datetime]:
288         """
289         Parse a ~free form date/time expression and return a
290         timezone agnostic datetime on success.  Also sets
291         `self.datetime`, `self.date` and `self.time` which can each be
292         accessed other methods on the class: :meth:`get_datetime`,
293         :meth:`get_date` and :meth:`get_time`.  Raises a
294         `ParseException` with a helpful(?) message on parse error or
295         confusion.
296
297         This is the main entrypoint to this class for caller code.
298
299         To get an idea of what expressions can be parsed, check out
300         the unittest and the grammar.
301
302         Args:
303             date_string: the string to parse
304
305         Returns:
306             A datetime.datetime representing the parsed date/time or
307             None on error.
308
309         .. note::
310
311             Parsed date expressions without any time part return
312             hours = minutes = seconds = microseconds = 0 (i.e. at
313             midnight that day).  Parsed time expressions without any
314             date part default to date = today.
315
316         Example usage::
317
318             txt = '3 weeks before last tues at 9:15am'
319             dp = DateParser()
320             dt1 = dp.parse(txt)
321             dt2 = dp.get_datetime(tz=pytz.timezone('US/Pacific'))
322
323             # dt1 and dt2 will be identical other than the fact that
324             # the latter's tzinfo will be set to PST/PDT.
325
326         """
327         date_string = date_string.strip()
328         date_string = re.sub(r'\s+', ' ', date_string)
329         self._reset()
330         listener = RaisingErrorListener()
331         input_stream = antlr4.InputStream(date_string)
332         lexer = dateparse_utilsLexer(input_stream)
333         lexer.removeErrorListeners()
334         lexer.addErrorListener(listener)
335         stream = antlr4.CommonTokenStream(lexer)
336         parser = dateparse_utilsParser(stream)
337         parser.removeErrorListeners()
338         parser.addErrorListener(listener)
339         tree = parser.parse()
340         walker = antlr4.ParseTreeWalker()
341         walker.walk(self, tree)
342         return self.datetime
343
344     def get_date(self) -> Optional[datetime.date]:
345         """
346         Returns:
347             The date part of the last :meth:`parse` operation again
348             or None.
349         """
350         return self.date
351
352     def get_time(self) -> Optional[datetime.time]:
353         """
354         Returns:
355             The time part of the last :meth:`parse` operation again
356             or None.
357         """
358         return self.time
359
360     def get_datetime(self, *, tz=None) -> Optional[datetime.datetime]:
361         """Get the datetime of the last :meth:`parse` operation again
362         ot None.
363
364         Args:
365             tz: the timezone to set on output times.  By default we
366                 return timezone-naive datetime objects.
367
368         Returns:
369             the same datetime that :meth:`parse` last did, optionally
370             overriding the timezone.  Returns None of no calls to
371             :meth:`parse` have yet been made.
372
373         .. note::
374
375             Parsed date expressions without any time part return
376             hours = minutes = seconds = microseconds = 0 (i.e. at
377             midnight that day).  Parsed time expressions without any
378             date part default to date = today.
379         """
380         dt = self.datetime
381         if dt is not None:
382             if tz is not None:
383                 dt = dt.replace(tzinfo=None).astimezone(tz=tz)
384         return dt
385
386     # -- helpers --
387
388     def _reset(self):
389         """Reset at init and between parses."""
390         if self.override_now_for_test_purposes is None:
391             self.now_datetime = datetime.datetime.now()
392             self.today = datetime.date.today()
393         else:
394             self.now_datetime = self.override_now_for_test_purposes
395             self.today = datetime_to_date(self.override_now_for_test_purposes)
396         self.date: Optional[datetime.date] = None
397         self.time: Optional[datetime.time] = None
398         self.datetime: Optional[datetime.datetime] = None
399         self.context: Dict[str, Any] = {}
400         self.timedelta = datetime.timedelta(seconds=0)
401         self.saw_overt_year = False
402
403     @staticmethod
404     def _normalize_special_day_name(name: str) -> str:
405         """String normalization / canonicalization for date expressions."""
406         name = name.lower()
407         name = name.replace("'", '')
408         name = name.replace('xmas', 'christmas')
409         name = name.replace('mlk', 'martin luther king')
410         name = name.replace(' ', '')
411         eve = 'eve' if name[-3:] == 'eve' else ''
412         name = name[:5] + eve
413         name = name.replace('washi', 'presi')
414         return name
415
416     def _figure_out_date_unit(self, orig: str) -> TimeUnit:
417         """Figure out what unit a date expression piece is talking about."""
418         if 'month' in orig:
419             return TimeUnit.MONTHS
420         txt = orig.lower()[:3]
421         if txt in self.day_name_to_number:
422             return TimeUnit(self.day_name_to_number[txt])
423         elif txt in self.delta_unit_to_constant:
424             return TimeUnit(self.delta_unit_to_constant[txt])
425         raise ParseException(f'Invalid date unit: {orig}')
426
427     def _figure_out_time_unit(self, orig: str) -> int:
428         """Figure out what unit a time expression piece is talking about."""
429         txt = orig.lower()[:3]
430         if txt in self.time_delta_unit_to_constant:
431             return self.time_delta_unit_to_constant[txt]
432         raise ParseException(f'Invalid time unit: {orig}')
433
434     def _parse_special_date(self, name: str) -> Optional[datetime.date]:
435         """Parse what we think is a special date name and return its datetime
436         (or None if it can't be parsed).
437         """
438         today = self.today
439         year = self.context.get('year', today.year)
440         name = DateParser._normalize_special_day_name(self.context['special'])
441
442         # Yesterday, today, tomorrow -- ignore any next/last
443         if name in ('today', 'now'):
444             return today
445         if name == 'yeste':
446             return today + datetime.timedelta(days=-1)
447         if name == 'tomor':
448             return today + datetime.timedelta(days=+1)
449
450         next_last = self.context.get('special_next_last', '')
451         if next_last == 'next':
452             year += 1
453             self.saw_overt_year = True
454         elif next_last == 'last':
455             year -= 1
456             self.saw_overt_year = True
457
458         # Holiday names
459         if name == 'easte':
460             return dateutil.easter.easter(year=year)
461         elif name == 'hallo':
462             return datetime.date(year=year, month=10, day=31)
463
464         for holiday_date, holiday_name in sorted(holidays.US(years=year).items()):
465             if 'Observed' not in holiday_name:
466                 holiday_name = DateParser._normalize_special_day_name(holiday_name)
467                 if name == holiday_name:
468                     return holiday_date
469         if name == 'chriseve':
470             return datetime.date(year=year, month=12, day=24)
471         elif name == 'newyeeve':
472             return datetime.date(year=year, month=12, day=31)
473         return None
474
475     def _resolve_ides_nones(self, day: str, month_number: int) -> int:
476         """Handle date expressions like "the ides of March" which require
477         both the "ides" and the month since the definition of the "ides"
478         changes based on the length of the month.
479         """
480         assert 'ide' in day or 'non' in day
481         assert month_number in self.typical_days_per_month
482         typical_days_per_month = self.typical_days_per_month[month_number]
483
484         # "full" month
485         if typical_days_per_month == 31:
486             if self.context['day'] == 'ide':
487                 return 15
488             else:
489                 return 7
490
491         # "hollow" month
492         else:
493             if self.context['day'] == 'ide':
494                 return 13
495             else:
496                 return 5
497
498     def _parse_normal_date(self) -> datetime.date:
499         if 'dow' in self.context and 'month' not in self.context:
500             d = self.today
501             while d.weekday() != self.context['dow']:
502                 d += datetime.timedelta(days=1)
503             return d
504
505         if 'month' not in self.context:
506             raise ParseException('Missing month')
507         if 'day' not in self.context:
508             raise ParseException('Missing day')
509         if 'year' not in self.context:
510             self.context['year'] = self.today.year
511             self.saw_overt_year = False
512         else:
513             self.saw_overt_year = True
514
515         # Handling "ides" and "nones" requires both the day and month.
516         if self.context['day'] == 'ide' or self.context['day'] == 'non':
517             self.context['day'] = self._resolve_ides_nones(
518                 self.context['day'], self.context['month']
519             )
520
521         return datetime.date(
522             year=self.context['year'],
523             month=self.context['month'],
524             day=self.context['day'],
525         )
526
527     @staticmethod
528     def _parse_tz(txt: str) -> Any:
529         if txt == 'Z':
530             txt = 'UTC'
531
532         # Try pytz
533         try:
534             tz1 = pytz.timezone(txt)
535             if tz1 is not None:
536                 return tz1
537         except Exception:
538             pass
539
540         # Try dateutil
541         try:
542             tz2 = dateutil.tz.gettz(txt)
543             if tz2 is not None:
544                 return tz2
545         except Exception:
546             pass
547
548         # Try constructing an offset in seconds
549         try:
550             txt_sign = txt[0]
551             if txt_sign in ('-', '+'):
552                 sign = +1 if txt_sign == '+' else -1
553                 hour = int(txt[1:3])
554                 minute = int(txt[-2:])
555                 offset = sign * (hour * 60 * 60) + sign * (minute * 60)
556                 tzoffset = dateutil.tz.tzoffset(txt, offset)
557                 return tzoffset
558         except Exception:
559             pass
560         return None
561
562     @staticmethod
563     def _get_int(txt: str) -> int:
564         while not txt[0].isdigit() and txt[0] != '-' and txt[0] != '+':
565             txt = txt[1:]
566         while not txt[-1].isdigit():
567             txt = txt[:-1]
568         return int(txt)
569
570     # -- overridden methods invoked by parse walk.  Note: not part of the class'
571     # public API(!!) --
572
573     def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
574         pass
575
576     def visitTerminal(self, node: antlr4.TerminalNode) -> None:
577         pass
578
579     def exitParse(self, ctx: dateparse_utilsParser.ParseContext) -> None:
580         """Populate self.datetime."""
581         if self.date is None:
582             self.date = self.today
583         year = self.date.year
584         month = self.date.month
585         day = self.date.day
586
587         if self.time is None:
588             self.time = datetime.time(0, 0, 0)
589         hour = self.time.hour
590         minute = self.time.minute
591         second = self.time.second
592         micros = self.time.microsecond
593
594         self.datetime = datetime.datetime(
595             year,
596             month,
597             day,
598             hour,
599             minute,
600             second,
601             micros,
602             tzinfo=self.time.tzinfo,
603         )
604
605         # Apply resudual adjustments to times here when we have a
606         # datetime.
607         self.datetime = self.datetime + self.timedelta
608         assert self.datetime is not None
609         self.time = datetime.time(
610             self.datetime.hour,
611             self.datetime.minute,
612             self.datetime.second,
613             self.datetime.microsecond,
614             self.datetime.tzinfo,
615         )
616
617     def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
618         self.date = None
619         if ctx.singleDateExpr() is not None:
620             self.main_type = DateParser.PARSE_TYPE_SINGLE_DATE_EXPR
621         elif ctx.baseAndOffsetDateExpr() is not None:
622             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
623
624     def enterTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext):
625         self.time = None
626         if ctx.singleTimeExpr() is not None:
627             self.time_type = DateParser.PARSE_TYPE_SINGLE_TIME_EXPR
628         elif ctx.baseAndOffsetTimeExpr() is not None:
629             self.time_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR
630
631     def exitDateExpr(self, ctx: dateparse_utilsParser.DateExprContext) -> None:
632         """When we leave the date expression, populate self.date."""
633         if 'special' in self.context:
634             self.date = self._parse_special_date(self.context['special'])
635         else:
636             self.date = self._parse_normal_date()
637         assert self.date is not None
638
639         # For a single date, just return the date we pulled out.
640         if self.main_type == DateParser.PARSE_TYPE_SINGLE_DATE_EXPR:
641             return
642
643         # Otherwise treat self.date as a base date that we're modifying
644         # with an offset.
645         if 'delta_int' not in self.context:
646             raise ParseException('Missing delta_int?!')
647         count = self.context['delta_int']
648         if count == 0:
649             return
650
651         # Adjust count's sign based on the presence of 'before' or 'after'.
652         if 'delta_before_after' in self.context:
653             before_after = self.context['delta_before_after'].lower()
654             if before_after in ('before', 'until', 'til', 'to'):
655                 count = -count
656
657         # What are we counting units of?
658         if 'delta_unit' not in self.context:
659             raise ParseException('Missing delta_unit?!')
660         unit = self.context['delta_unit']
661         dt = n_timeunits_from_base(count, TimeUnit(unit), date_to_datetime(self.date))
662         self.date = datetime_to_date(dt)
663
664     def exitTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext) -> None:
665         # Simple time?
666         self.time = datetime.time(
667             self.context['hour'],
668             self.context['minute'],
669             self.context['seconds'],
670             self.context['micros'],
671             tzinfo=self.context.get('tz', None),
672         )
673         if self.time_type == DateParser.PARSE_TYPE_SINGLE_TIME_EXPR:
674             return
675
676         # If we get here there (should be) a relative adjustment to
677         # the time.
678         if 'nth' in self.context:
679             count = self.context['nth']
680         elif 'time_delta_int' in self.context:
681             count = self.context['time_delta_int']
682         else:
683             raise ParseException('Missing delta in relative time.')
684         if count == 0:
685             return
686
687         # Adjust count's sign based on the presence of 'before' or 'after'.
688         if 'time_delta_before_after' in self.context:
689             before_after = self.context['time_delta_before_after'].lower()
690             if before_after in ('before', 'until', 'til', 'to'):
691                 count = -count
692
693         # What are we counting units of... assume minutes.
694         if 'time_delta_unit' not in self.context:
695             self.timedelta += datetime.timedelta(minutes=count)
696         else:
697             unit = self.context['time_delta_unit']
698             if unit == TimeUnit.SECONDS:
699                 self.timedelta += datetime.timedelta(seconds=count)
700             elif unit == TimeUnit.MINUTES:
701                 self.timedelta = datetime.timedelta(minutes=count)
702             elif unit == TimeUnit.HOURS:
703                 self.timedelta = datetime.timedelta(hours=count)
704             else:
705                 raise ParseException(f'Invalid Unit: "{unit}"')
706
707     def exitDeltaPlusMinusExpr(
708         self, ctx: dateparse_utilsParser.DeltaPlusMinusExprContext
709     ) -> None:
710         try:
711             n = ctx.nth()
712             if n is None:
713                 raise ParseException(f'Bad N in Delta +/- Expr: {ctx.getText()}')
714             n = n.getText()
715             n = DateParser._get_int(n)
716             unit = self._figure_out_date_unit(ctx.deltaUnit().getText().lower())
717         except Exception as e:
718             raise ParseException(f'Invalid Delta +/-: {ctx.getText()}') from e
719         else:
720             self.context['delta_int'] = n
721             self.context['delta_unit'] = unit
722
723     def exitNextLastUnit(self, ctx: dateparse_utilsParser.DeltaUnitContext) -> None:
724         try:
725             unit = self._figure_out_date_unit(ctx.getText().lower())
726         except Exception as e:
727             raise ParseException(f'Bad delta unit: {ctx.getText()}') from e
728         else:
729             self.context['delta_unit'] = unit
730
731     def exitDeltaNextLast(
732         self, ctx: dateparse_utilsParser.DeltaNextLastContext
733     ) -> None:
734         try:
735             txt = ctx.getText().lower()
736         except Exception as e:
737             raise ParseException(f'Bad next/last: {ctx.getText()}') from e
738         if 'month' in self.context or 'day' in self.context or 'year' in self.context:
739             raise ParseException(
740                 'Next/last expression expected to be relative to today.'
741             )
742         if txt[:4] == 'next':
743             self.context['delta_int'] = +1
744             self.context['day'] = self.now_datetime.day
745             self.context['month'] = self.now_datetime.month
746             self.context['year'] = self.now_datetime.year
747             self.saw_overt_year = True
748         elif txt[:4] == 'last':
749             self.context['delta_int'] = -1
750             self.context['day'] = self.now_datetime.day
751             self.context['month'] = self.now_datetime.month
752             self.context['year'] = self.now_datetime.year
753             self.saw_overt_year = True
754         else:
755             raise ParseException(f'Bad next/last: {ctx.getText()}')
756
757     def exitCountUnitsBeforeAfterTimeExpr(
758         self, ctx: dateparse_utilsParser.CountUnitsBeforeAfterTimeExprContext
759     ) -> None:
760         if 'nth' not in self.context:
761             raise ParseException(f'Bad count expression: {ctx.getText()}')
762         try:
763             unit = self._figure_out_time_unit(ctx.deltaTimeUnit().getText().lower())
764             self.context['time_delta_unit'] = unit
765         except Exception as e:
766             raise ParseException(f'Bad delta unit: {ctx.getText()}') from e
767         if 'time_delta_before_after' not in self.context:
768             raise ParseException(f'Bad Before/After: {ctx.getText()}')
769
770     def exitDeltaTimeFraction(
771         self, ctx: dateparse_utilsParser.DeltaTimeFractionContext
772     ) -> None:
773         try:
774             txt = ctx.getText().lower()[:4]
775             if txt == 'quar':
776                 self.context['time_delta_int'] = 15
777                 self.context['time_delta_unit'] = TimeUnit.MINUTES
778             elif txt == 'half':
779                 self.context['time_delta_int'] = 30
780                 self.context['time_delta_unit'] = TimeUnit.MINUTES
781             else:
782                 raise ParseException(f'Bad time fraction {ctx.getText()}')
783         except Exception as e:
784             raise ParseException(f'Bad time fraction {ctx.getText()}') from e
785
786     def exitDeltaBeforeAfter(
787         self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
788     ) -> None:
789         try:
790             txt = ctx.getText().lower()
791         except Exception as e:
792             raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e
793         else:
794             self.context['delta_before_after'] = txt
795
796     def exitDeltaTimeBeforeAfter(
797         self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
798     ) -> None:
799         try:
800             txt = ctx.getText().lower()
801         except Exception as e:
802             raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e
803         else:
804             self.context['time_delta_before_after'] = txt
805
806     def exitNthWeekdayInMonthMaybeYearExpr(
807         self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext
808     ) -> None:
809         """Do a bunch of work to convert expressions like...
810
811         'the 2nd Friday of June' -and-
812         'the last Wednesday in October'
813
814         ...into base + offset expressions instead.
815         """
816         try:
817             if 'nth' not in self.context:
818                 raise ParseException(f'Missing nth number: {ctx.getText()}')
819             n = self.context['nth']
820             if n < 1 or n > 5:  # months never have more than 5 Foodays
821                 if n != -1:
822                     raise ParseException(f'Invalid nth number: {ctx.getText()}')
823             del self.context['nth']
824             self.context['delta_int'] = n
825
826             year = self.context.get('year', self.today.year)
827             if 'month' not in self.context:
828                 raise ParseException(f'Missing month expression: {ctx.getText()}')
829             month = self.context['month']
830
831             dow = self.context['dow']
832             del self.context['dow']
833             self.context['delta_unit'] = dow
834
835             # For the nth Fooday in Month, start at the last day of
836             # the previous month count ahead N Foodays.  For the last
837             # Fooday in Month, start at the last of the month and
838             # count back one Fooday.
839             if n == -1:
840                 month += 1
841                 if month == 13:
842                     month = 1
843                     year += 1
844                 tmp_date = datetime.date(year=year, month=month, day=1)
845                 tmp_date = tmp_date - datetime.timedelta(days=1)
846
847                 # The delta adjustment code can handle the case where
848                 # the last day of the month is the day we're looking
849                 # for already.
850             else:
851                 tmp_date = datetime.date(year=year, month=month, day=1)
852                 tmp_date = tmp_date - datetime.timedelta(days=1)
853
854             self.context['year'] = tmp_date.year
855             self.context['month'] = tmp_date.month
856             self.context['day'] = tmp_date.day
857             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
858         except Exception as e:
859             raise ParseException(
860                 f'Invalid nthWeekday expression: {ctx.getText()}'
861             ) from e
862
863     def exitFirstLastWeekdayInMonthMaybeYearExpr(
864         self,
865         ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext,
866     ) -> None:
867         self.exitNthWeekdayInMonthMaybeYearExpr(ctx)
868
869     def exitNth(self, ctx: dateparse_utilsParser.NthContext) -> None:
870         try:
871             i = DateParser._get_int(ctx.getText())
872         except Exception as e:
873             raise ParseException(f'Bad nth expression: {ctx.getText()}') from e
874         else:
875             self.context['nth'] = i
876
877     def exitFirstOrLast(self, ctx: dateparse_utilsParser.FirstOrLastContext) -> None:
878         try:
879             txt = ctx.getText()
880             if txt == 'first':
881                 txt = 1
882             elif txt == 'last':
883                 txt = -1
884             else:
885                 raise ParseException(f'Bad first|last expression: {ctx.getText()}')
886         except Exception as e:
887             raise ParseException(f'Bad first|last expression: {ctx.getText()}') from e
888         else:
889             self.context['nth'] = txt
890
891     def exitDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None:
892         try:
893             dow = ctx.getText().lower()[:3]
894             dow = self.day_name_to_number.get(dow, None)
895         except Exception as e:
896             raise ParseException('Bad day of week') from e
897         else:
898             self.context['dow'] = dow
899
900     def exitDayOfMonth(self, ctx: dateparse_utilsParser.DayOfMonthContext) -> None:
901         try:
902             day = ctx.getText().lower()
903             if day[:3] == 'ide':
904                 self.context['day'] = 'ide'
905                 return
906             if day[:3] == 'non':
907                 self.context['day'] = 'non'
908                 return
909             if day[:3] == 'kal':
910                 self.context['day'] = 1
911                 return
912             day = DateParser._get_int(day)
913             if day < 1 or day > 31:
914                 raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}')
915         except Exception as e:
916             raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') from e
917         self.context['day'] = day
918
919     def exitMonthName(self, ctx: dateparse_utilsParser.MonthNameContext) -> None:
920         try:
921             month = ctx.getText()
922             while month[0] == '/' or month[0] == '-':
923                 month = month[1:]
924             month = month[:3].lower()
925             month = self.month_name_to_number.get(month, None)
926             if month is None:
927                 raise ParseException(f'Bad monthName expression: {ctx.getText()}')
928         except Exception as e:
929             raise ParseException(f'Bad monthName expression: {ctx.getText()}') from e
930         else:
931             self.context['month'] = month
932
933     def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None:
934         try:
935             month = DateParser._get_int(ctx.getText())
936             if month < 1 or month > 12:
937                 raise ParseException(f'Bad monthNumber expression: {ctx.getText()}')
938         except Exception as e:
939             raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') from e
940         else:
941             self.context['month'] = month
942
943     def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
944         try:
945             year = DateParser._get_int(ctx.getText())
946             if year < 1:
947                 raise ParseException(f'Bad year expression: {ctx.getText()}')
948         except Exception as e:
949             raise ParseException(f'Bad year expression: {ctx.getText()}') from e
950         else:
951             self.saw_overt_year = True
952             self.context['year'] = year
953
954     def exitSpecialDateMaybeYearExpr(
955         self, ctx: dateparse_utilsParser.SpecialDateMaybeYearExprContext
956     ) -> None:
957         try:
958             special = ctx.specialDate().getText().lower()
959             self.context['special'] = special
960         except Exception as e:
961             raise ParseException(
962                 f'Bad specialDate expression: {ctx.specialDate().getText()}'
963             ) from e
964         try:
965             mod = ctx.thisNextLast()
966             if mod is not None:
967                 if mod.THIS() is not None:
968                     self.context['special_next_last'] = 'this'
969                 elif mod.NEXT() is not None:
970                     self.context['special_next_last'] = 'next'
971                 elif mod.LAST() is not None:
972                     self.context['special_next_last'] = 'last'
973         except Exception as e:
974             raise ParseException(
975                 f'Bad specialDateNextLast expression: {ctx.getText()}'
976             ) from e
977
978     def exitNFoosFromTodayAgoExpr(
979         self, ctx: dateparse_utilsParser.NFoosFromTodayAgoExprContext
980     ) -> None:
981         d = self.now_datetime
982         try:
983             count = DateParser._get_int(ctx.unsignedInt().getText())
984             unit = ctx.deltaUnit().getText().lower()
985             ago_from_now = ctx.AGO_FROM_NOW().getText()
986         except Exception as e:
987             raise ParseException(f'Bad NFoosFromTodayAgoExpr: {ctx.getText()}') from e
988
989         if "ago" in ago_from_now or "back" in ago_from_now:
990             count = -count
991
992         unit = self._figure_out_date_unit(unit)
993         d = n_timeunits_from_base(count, TimeUnit(unit), d)
994         self.context['year'] = d.year
995         self.context['month'] = d.month
996         self.context['day'] = d.day
997
998     def exitDeltaRelativeToTodayExpr(
999         self, ctx: dateparse_utilsParser.DeltaRelativeToTodayExprContext
1000     ) -> None:
1001         # When someone says "next week" they mean a week from now.
1002         # Likewise next month or last year.  These expressions are now
1003         # +/- delta.
1004         #
1005         # But when someone says "this Friday" they mean "this coming
1006         # Friday".  It would be weird to say "this Friday" if today
1007         # was already Friday but I'm parsing it to mean: the next day
1008         # that is a Friday.  So when you say "next Friday" you mean
1009         # the Friday after this coming Friday, or 2 Fridays from now.
1010         #
1011         # This set handles this weirdness.
1012         weekdays = set(
1013             [
1014                 TimeUnit.MONDAYS,
1015                 TimeUnit.TUESDAYS,
1016                 TimeUnit.WEDNESDAYS,
1017                 TimeUnit.THURSDAYS,
1018                 TimeUnit.FRIDAYS,
1019                 TimeUnit.SATURDAYS,
1020                 TimeUnit.SUNDAYS,
1021             ]
1022         )
1023         d = self.now_datetime
1024         try:
1025             mod = ctx.thisNextLast()
1026             unit = ctx.deltaUnit().getText().lower()
1027             unit = self._figure_out_date_unit(unit)
1028             if mod.LAST():
1029                 count = -1
1030             elif mod.THIS():
1031                 if unit in weekdays:
1032                     count = +1
1033                 else:
1034                     count = 0
1035             elif mod.NEXT():
1036                 if unit in weekdays:
1037                     count = +2
1038                 else:
1039                     count = +1
1040             else:
1041                 raise ParseException(f'Bad This/Next/Last modifier: {mod}')
1042         except Exception as e:
1043             raise ParseException(
1044                 f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}'
1045             ) from e
1046         d = n_timeunits_from_base(count, TimeUnit(unit), d)
1047         self.context['year'] = d.year
1048         self.context['month'] = d.month
1049         self.context['day'] = d.day
1050
1051     def exitSpecialTimeExpr(
1052         self, ctx: dateparse_utilsParser.SpecialTimeExprContext
1053     ) -> None:
1054         try:
1055             txt = ctx.specialTime().getText().lower()
1056         except Exception as e:
1057             raise ParseException(f'Bad special time expression: {ctx.getText()}') from e
1058         else:
1059             if txt in ('noon', 'midday'):
1060                 self.context['hour'] = 12
1061                 self.context['minute'] = 0
1062                 self.context['seconds'] = 0
1063                 self.context['micros'] = 0
1064             elif txt == 'midnight':
1065                 self.context['hour'] = 0
1066                 self.context['minute'] = 0
1067                 self.context['seconds'] = 0
1068                 self.context['micros'] = 0
1069             else:
1070                 raise ParseException(f'Bad special time expression: {txt}')
1071
1072         try:
1073             tz = ctx.tzExpr().getText()
1074             self.context['tz'] = DateParser._parse_tz(tz)
1075         except Exception:
1076             pass
1077
1078     def exitTwelveHourTimeExpr(
1079         self, ctx: dateparse_utilsParser.TwelveHourTimeExprContext
1080     ) -> None:
1081         try:
1082             hour = ctx.hour().getText()
1083             while not hour[-1].isdigit():
1084                 hour = hour[:-1]
1085             hour = DateParser._get_int(hour)
1086         except Exception as e:
1087             raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e
1088         if hour <= 0 or hour > 12:
1089             raise ParseException(f'Bad hour (out of range): {hour}')
1090
1091         try:
1092             minute = DateParser._get_int(ctx.minute().getText())
1093         except Exception:
1094             minute = 0
1095         if minute < 0 or minute > 59:
1096             raise ParseException(f'Bad minute (out of range): {minute}')
1097         self.context['minute'] = minute
1098
1099         try:
1100             seconds = DateParser._get_int(ctx.second().getText())
1101         except Exception:
1102             seconds = 0
1103         if seconds < 0 or seconds > 59:
1104             raise ParseException(f'Bad second (out of range): {seconds}')
1105         self.context['seconds'] = seconds
1106
1107         try:
1108             micros = DateParser._get_int(ctx.micros().getText())
1109         except Exception:
1110             micros = 0
1111         if micros < 0 or micros > 1000000:
1112             raise ParseException(f'Bad micros (out of range): {micros}')
1113         self.context['micros'] = micros
1114
1115         try:
1116             ampm = ctx.ampm().getText()
1117         except Exception as e:
1118             raise ParseException(f'Bad ampm: {ctx.ampm().getText()}') from e
1119         if hour == 12:
1120             hour = 0
1121         if ampm[0] == 'p':
1122             hour += 12
1123         self.context['hour'] = hour
1124
1125         try:
1126             tz = ctx.tzExpr().getText()
1127             self.context['tz'] = DateParser._parse_tz(tz)
1128         except Exception:
1129             pass
1130
1131     def exitTwentyFourHourTimeExpr(
1132         self, ctx: dateparse_utilsParser.TwentyFourHourTimeExprContext
1133     ) -> None:
1134         try:
1135             hour = ctx.hour().getText()
1136             while not hour[-1].isdigit():
1137                 hour = hour[:-1]
1138             hour = DateParser._get_int(hour)
1139         except Exception as e:
1140             raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e
1141         if hour < 0 or hour > 23:
1142             raise ParseException(f'Bad hour (out of range): {hour}')
1143         self.context['hour'] = hour
1144
1145         try:
1146             minute = DateParser._get_int(ctx.minute().getText())
1147         except Exception:
1148             minute = 0
1149         if minute < 0 or minute > 59:
1150             raise ParseException(f'Bad minute (out of range): {ctx.getText()}')
1151         self.context['minute'] = minute
1152
1153         try:
1154             seconds = DateParser._get_int(ctx.second().getText())
1155         except Exception:
1156             seconds = 0
1157         if seconds < 0 or seconds > 59:
1158             raise ParseException(f'Bad second (out of range): {ctx.getText()}')
1159         self.context['seconds'] = seconds
1160
1161         try:
1162             micros = DateParser._get_int(ctx.micros().getText())
1163         except Exception:
1164             micros = 0
1165         if micros < 0 or micros >= 1000000:
1166             raise ParseException(f'Bad micros (out of range): {ctx.getText()}')
1167         self.context['micros'] = micros
1168
1169         try:
1170             tz = ctx.tzExpr().getText()
1171             self.context['tz'] = DateParser._parse_tz(tz)
1172         except Exception:
1173             pass
1174
1175
1176 @bootstrap.initialize
1177 def main() -> None:
1178     parser = DateParser()
1179     for line in sys.stdin:
1180         line = line.strip()
1181         line = re.sub(r"#.*$", "", line)
1182         if re.match(r"^ *$", line) is not None:
1183             continue
1184         try:
1185             dt = parser.parse(line)
1186         except Exception as e:
1187             logger.exception(e)
1188             print("Unrecognized.")
1189         else:
1190             assert dt is not None
1191             print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)'))
1192     sys.exit(0)
1193
1194
1195 if __name__ == "__main__":
1196     main()