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