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