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