More spring cleaning.
[pyutils.git] / src / pyutils / datetimes / dateparse_utils.py
1 #!/usr/bin/env python3
2 # type: ignore
3 # pylint: disable=too-many-public-methods
4 # pylint: disable=too-many-instance-attributes
5
6 # © Copyright 2021-2023, 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(
360         self, *, tz: Optional[datetime.tzinfo] = None
361     ) -> Optional[datetime.datetime]:
362         """Get the datetime of the last :meth:`parse` operation again
363         ot None.
364
365         Args:
366             tz: the timezone to set on output times.  By default we
367                 return timezone-naive datetime objects.
368
369         Returns:
370             the same datetime that :meth:`parse` last did, optionally
371             overriding the timezone.  Returns None of no calls to
372             :meth:`parse` have yet been made.
373
374         .. note::
375
376             Parsed date expressions without any time part return
377             hours = minutes = seconds = microseconds = 0 (i.e. at
378             midnight that day).  Parsed time expressions without any
379             date part default to date = today.
380         """
381         dt = self.datetime
382         if dt is not None:
383             if tz is not None:
384                 dt = dt.replace(tzinfo=None).astimezone(tz=tz)
385         return dt
386
387     # -- helpers --
388
389     def _reset(self):
390         """Reset at init and between parses."""
391         if self.override_now_for_test_purposes is None:
392             self.now_datetime = datetime.datetime.now()
393             self.today = datetime.date.today()
394         else:
395             self.now_datetime = self.override_now_for_test_purposes
396             self.today = datetime_to_date(self.override_now_for_test_purposes)
397         self.date: Optional[datetime.date] = None
398         self.time: Optional[datetime.time] = None
399         self.datetime: Optional[datetime.datetime] = None
400         self.context: Dict[str, Any] = {}
401         self.timedelta = datetime.timedelta(seconds=0)
402         self.saw_overt_year = False
403
404     @staticmethod
405     def _normalize_special_day_name(name: str) -> str:
406         """String normalization / canonicalization for date expressions."""
407         name = name.lower()
408         name = name.replace("'", '')
409         name = name.replace('xmas', 'christmas')
410         name = name.replace('mlk', 'martin luther king')
411         name = name.replace(' ', '')
412         eve = 'eve' if name[-3:] == 'eve' else ''
413         name = name[:5] + eve
414         name = name.replace('washi', 'presi')
415         return name
416
417     def _figure_out_date_unit(self, orig: str) -> TimeUnit:
418         """Figure out what unit a date expression piece is talking about."""
419         if 'month' in orig:
420             return TimeUnit.MONTHS
421         txt = orig.lower()[:3]
422         if txt in self.day_name_to_number:
423             return TimeUnit(self.day_name_to_number[txt])
424         elif txt in self.delta_unit_to_constant:
425             return TimeUnit(self.delta_unit_to_constant[txt])
426         raise ParseException(f'Invalid date unit: {orig}')
427
428     def _figure_out_time_unit(self, orig: str) -> int:
429         """Figure out what unit a time expression piece is talking about."""
430         txt = orig.lower()[:3]
431         if txt in self.time_delta_unit_to_constant:
432             return self.time_delta_unit_to_constant[txt]
433         raise ParseException(f'Invalid time unit: {orig}')
434
435     def _parse_special_date(self, name: str) -> Optional[datetime.date]:
436         """Parse what we think is a special date name and return its datetime
437         (or None if it can't be parsed).
438         """
439         today = self.today
440         year = self.context.get('year', today.year)
441         name = DateParser._normalize_special_day_name(self.context['special'])
442
443         # Yesterday, today, tomorrow -- ignore any next/last
444         if name in {'today', 'now'}:
445             return today
446         if name == 'yeste':
447             return today + datetime.timedelta(days=-1)
448         if name == 'tomor':
449             return today + datetime.timedelta(days=+1)
450
451         next_last = self.context.get('special_next_last', '')
452         if next_last == 'next':
453             year += 1
454             self.saw_overt_year = True
455         elif next_last == 'last':
456             year -= 1
457             self.saw_overt_year = True
458
459         # Holiday names
460         if name == 'easte':
461             return easter(year=year)
462         elif name == 'hallo':
463             return datetime.date(year=year, month=10, day=31)
464
465         for holiday_date, holiday_name in sorted(holidays.US(years=year).items()):
466             if 'Observed' not in holiday_name:
467                 holiday_name = DateParser._normalize_special_day_name(holiday_name)
468                 if name == holiday_name:
469                     return holiday_date
470         if name == 'chriseve':
471             return datetime.date(year=year, month=12, day=24)
472         elif name == 'newyeeve':
473             return datetime.date(year=year, month=12, day=31)
474         return None
475
476     def _resolve_ides_nones(self, day: str, month_number: int) -> int:
477         """Handle date expressions like "the ides of March" which require
478         both the "ides" and the month since the definition of the "ides"
479         changes based on the length of the month.
480         """
481         assert 'ide' in day or 'non' in day
482         assert month_number in self.typical_days_per_month
483         typical_days_per_month = self.typical_days_per_month[month_number]
484
485         # "full" month
486         if typical_days_per_month == 31:
487             if self.context['day'] == 'ide':
488                 return 15
489             else:
490                 return 7
491
492         # "hollow" month
493         else:
494             if self.context['day'] == 'ide':
495                 return 13
496             else:
497                 return 5
498
499     def _parse_normal_date(self) -> datetime.date:
500         if 'dow' in self.context and 'month' not in self.context:
501             d = self.today
502             while d.weekday() != self.context['dow']:
503                 d += datetime.timedelta(days=1)
504             return d
505
506         if 'month' not in self.context:
507             raise ParseException('Missing month')
508         if 'day' not in self.context:
509             raise ParseException('Missing day')
510         if 'year' not in self.context:
511             self.context['year'] = self.today.year
512             self.saw_overt_year = False
513         else:
514             self.saw_overt_year = True
515
516         # Handling "ides" and "nones" requires both the day and month.
517         if self.context['day'] == 'ide' or self.context['day'] == 'non':
518             self.context['day'] = self._resolve_ides_nones(
519                 self.context['day'], self.context['month']
520             )
521
522         return datetime.date(
523             year=self.context['year'],
524             month=self.context['month'],
525             day=self.context['day'],
526         )
527
528     @staticmethod
529     def _parse_tz(txt: str) -> Any:
530         if txt == 'Z':
531             txt = 'UTC'
532
533         # Try pytz
534         try:
535             tz1 = pytz.timezone(txt)
536             if tz1 is not None:
537                 return tz1
538         except Exception:
539             pass
540
541         # Try constructing an offset in seconds
542         try:
543             txt_sign = txt[0]
544             if txt_sign in {'-', '+'}:
545                 sign = +1 if txt_sign == '+' else -1
546                 hour = int(txt[1:3])
547                 minute = int(txt[-2:])
548                 offset = sign * (hour * 60 * 60) + sign * (minute * 60)
549                 tzoffset = datetime.timezone(datetime.timedelta(seconds=offset))
550                 return tzoffset
551         except Exception:
552             pass
553         return None
554
555     @staticmethod
556     def _get_int(txt: str) -> int:
557         while not txt[0].isdigit() and txt[0] != '-' and txt[0] != '+':
558             txt = txt[1:]
559         while not txt[-1].isdigit():
560             txt = txt[:-1]
561         return int(txt)
562
563     # -- overridden methods invoked by parse walk.  Note: not part of the class'
564     # public API(!!) --
565
566     def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
567         pass
568
569     def visitTerminal(self, node: antlr4.TerminalNode) -> None:
570         pass
571
572     def exitParse(self, ctx: dateparse_utilsParser.ParseContext) -> None:
573         """Populate self.datetime."""
574         if self.date is None:
575             self.date = self.today
576         year = self.date.year
577         month = self.date.month
578         day = self.date.day
579
580         if self.time is None:
581             self.time = datetime.time(0, 0, 0)
582         hour = self.time.hour
583         minute = self.time.minute
584         second = self.time.second
585         micros = self.time.microsecond
586
587         self.datetime = datetime.datetime(
588             year,
589             month,
590             day,
591             hour,
592             minute,
593             second,
594             micros,
595             tzinfo=self.time.tzinfo,
596         )
597
598         # Apply resudual adjustments to times here when we have a
599         # datetime.
600         self.datetime = self.datetime + self.timedelta
601         assert self.datetime is not None
602         self.time = datetime.time(
603             self.datetime.hour,
604             self.datetime.minute,
605             self.datetime.second,
606             self.datetime.microsecond,
607             self.datetime.tzinfo,
608         )
609
610     def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
611         self.date = None
612         if ctx.singleDateExpr() is not None:
613             self.main_type = DateParser.PARSE_TYPE_SINGLE_DATE_EXPR
614         elif ctx.baseAndOffsetDateExpr() is not None:
615             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
616
617     def enterTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext):
618         self.time = None
619         if ctx.singleTimeExpr() is not None:
620             self.time_type = DateParser.PARSE_TYPE_SINGLE_TIME_EXPR
621         elif ctx.baseAndOffsetTimeExpr() is not None:
622             self.time_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR
623
624     def exitDateExpr(self, ctx: dateparse_utilsParser.DateExprContext) -> None:
625         """When we leave the date expression, populate self.date."""
626         if 'special' in self.context:
627             self.date = self._parse_special_date(self.context['special'])
628         else:
629             self.date = self._parse_normal_date()
630         assert self.date is not None
631
632         # For a single date, just return the date we pulled out.
633         if self.main_type == DateParser.PARSE_TYPE_SINGLE_DATE_EXPR:
634             return
635
636         # Otherwise treat self.date as a base date that we're modifying
637         # with an offset.
638         if 'delta_int' not in self.context:
639             raise ParseException('Missing delta_int?!')
640         count = self.context['delta_int']
641         if count == 0:
642             return
643
644         # Adjust count's sign based on the presence of 'before' or 'after'.
645         if 'delta_before_after' in self.context:
646             before_after = self.context['delta_before_after'].lower()
647             if before_after in {'before', 'until', 'til', 'to'}:
648                 count = -count
649
650         # What are we counting units of?
651         if 'delta_unit' not in self.context:
652             raise ParseException('Missing delta_unit?!')
653         unit = self.context['delta_unit']
654         dt = n_timeunits_from_base(count, TimeUnit(unit), date_to_datetime(self.date))
655         self.date = datetime_to_date(dt)
656
657     def exitTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext) -> None:
658         # Simple time?
659         self.time = datetime.time(
660             self.context['hour'],
661             self.context['minute'],
662             self.context['seconds'],
663             self.context['micros'],
664             tzinfo=self.context.get('tz', None),
665         )
666         if self.time_type == DateParser.PARSE_TYPE_SINGLE_TIME_EXPR:
667             return
668
669         # If we get here there (should be) a relative adjustment to
670         # the time.
671         if 'nth' in self.context:
672             count = self.context['nth']
673         elif 'time_delta_int' in self.context:
674             count = self.context['time_delta_int']
675         else:
676             raise ParseException('Missing delta in relative time.')
677         if count == 0:
678             return
679
680         # Adjust count's sign based on the presence of 'before' or 'after'.
681         if 'time_delta_before_after' in self.context:
682             before_after = self.context['time_delta_before_after'].lower()
683             if before_after in {'before', 'until', 'til', 'to'}:
684                 count = -count
685
686         # What are we counting units of... assume minutes.
687         if 'time_delta_unit' not in self.context:
688             self.timedelta += datetime.timedelta(minutes=count)
689         else:
690             unit = self.context['time_delta_unit']
691             if unit == TimeUnit.SECONDS:
692                 self.timedelta += datetime.timedelta(seconds=count)
693             elif unit == TimeUnit.MINUTES:
694                 self.timedelta = datetime.timedelta(minutes=count)
695             elif unit == TimeUnit.HOURS:
696                 self.timedelta = datetime.timedelta(hours=count)
697             else:
698                 raise ParseException(f'Invalid Unit: "{unit}"')
699
700     def exitDeltaPlusMinusExpr(
701         self, ctx: dateparse_utilsParser.DeltaPlusMinusExprContext
702     ) -> None:
703         try:
704             n = ctx.nth()
705             if n is None:
706                 raise ParseException(f'Bad N in Delta +/- Expr: {ctx.getText()}')
707             n = n.getText()
708             n = DateParser._get_int(n)
709             unit = self._figure_out_date_unit(ctx.deltaUnit().getText().lower())
710         except Exception as e:
711             raise ParseException(f'Invalid Delta +/-: {ctx.getText()}') from e
712         else:
713             self.context['delta_int'] = n
714             self.context['delta_unit'] = unit
715
716     def exitNextLastUnit(self, ctx: dateparse_utilsParser.DeltaUnitContext) -> None:
717         try:
718             unit = self._figure_out_date_unit(ctx.getText().lower())
719         except Exception as e:
720             raise ParseException(f'Bad delta unit: {ctx.getText()}') from e
721         else:
722             self.context['delta_unit'] = unit
723
724     def exitDeltaNextLast(
725         self, ctx: dateparse_utilsParser.DeltaNextLastContext
726     ) -> None:
727         try:
728             txt = ctx.getText().lower()
729         except Exception as e:
730             raise ParseException(f'Bad next/last: {ctx.getText()}') from e
731         if 'month' in self.context or 'day' in self.context or 'year' in self.context:
732             raise ParseException(
733                 'Next/last expression expected to be relative to today.'
734             )
735         if txt[:4] == 'next':
736             self.context['delta_int'] = +1
737             self.context['day'] = self.now_datetime.day
738             self.context['month'] = self.now_datetime.month
739             self.context['year'] = self.now_datetime.year
740             self.saw_overt_year = True
741         elif txt[:4] == 'last':
742             self.context['delta_int'] = -1
743             self.context['day'] = self.now_datetime.day
744             self.context['month'] = self.now_datetime.month
745             self.context['year'] = self.now_datetime.year
746             self.saw_overt_year = True
747         else:
748             raise ParseException(f'Bad next/last: {ctx.getText()}')
749
750     def exitCountUnitsBeforeAfterTimeExpr(
751         self, ctx: dateparse_utilsParser.CountUnitsBeforeAfterTimeExprContext
752     ) -> None:
753         if 'nth' not in self.context:
754             raise ParseException(f'Bad count expression: {ctx.getText()}')
755         try:
756             unit = self._figure_out_time_unit(ctx.deltaTimeUnit().getText().lower())
757             self.context['time_delta_unit'] = unit
758         except Exception as e:
759             raise ParseException(f'Bad delta unit: {ctx.getText()}') from e
760         if 'time_delta_before_after' not in self.context:
761             raise ParseException(f'Bad Before/After: {ctx.getText()}')
762
763     def exitDeltaTimeFraction(
764         self, ctx: dateparse_utilsParser.DeltaTimeFractionContext
765     ) -> None:
766         try:
767             txt = ctx.getText().lower()[:4]
768             if txt == 'quar':
769                 self.context['time_delta_int'] = 15
770                 self.context['time_delta_unit'] = TimeUnit.MINUTES
771             elif txt == 'half':
772                 self.context['time_delta_int'] = 30
773                 self.context['time_delta_unit'] = TimeUnit.MINUTES
774             else:
775                 raise ParseException(f'Bad time fraction {ctx.getText()}')
776         except Exception as e:
777             raise ParseException(f'Bad time fraction {ctx.getText()}') from e
778
779     def exitDeltaBeforeAfter(
780         self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
781     ) -> None:
782         try:
783             txt = ctx.getText().lower()
784         except Exception as e:
785             raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e
786         else:
787             self.context['delta_before_after'] = txt
788
789     def exitDeltaTimeBeforeAfter(
790         self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
791     ) -> None:
792         try:
793             txt = ctx.getText().lower()
794         except Exception as e:
795             raise ParseException(f'Bad delta before|after: {ctx.getText()}') from e
796         else:
797             self.context['time_delta_before_after'] = txt
798
799     def exitNthWeekdayInMonthMaybeYearExpr(
800         self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext
801     ) -> None:
802         """Do a bunch of work to convert expressions like...
803
804         'the 2nd Friday of June' -and-
805         'the last Wednesday in October'
806
807         ...into base + offset expressions instead.
808         """
809         try:
810             if 'nth' not in self.context:
811                 raise ParseException(f'Missing nth number: {ctx.getText()}')
812             n = self.context['nth']
813             if n < 1 or n > 5:  # months never have more than 5 Foodays
814                 if n != -1:
815                     raise ParseException(f'Invalid nth number: {ctx.getText()}')
816             del self.context['nth']
817             self.context['delta_int'] = n
818
819             year = self.context.get('year', self.today.year)
820             if 'month' not in self.context:
821                 raise ParseException(f'Missing month expression: {ctx.getText()}')
822             month = self.context['month']
823
824             dow = self.context['dow']
825             del self.context['dow']
826             self.context['delta_unit'] = dow
827
828             # For the nth Fooday in Month, start at the last day of
829             # the previous month count ahead N Foodays.  For the last
830             # Fooday in Month, start at the last of the month and
831             # count back one Fooday.
832             if n == -1:
833                 month += 1
834                 if month == 13:
835                     month = 1
836                     year += 1
837                 tmp_date = datetime.date(year=year, month=month, day=1)
838                 tmp_date = tmp_date - datetime.timedelta(days=1)
839
840                 # The delta adjustment code can handle the case where
841                 # the last day of the month is the day we're looking
842                 # for already.
843             else:
844                 tmp_date = datetime.date(year=year, month=month, day=1)
845                 tmp_date = tmp_date - datetime.timedelta(days=1)
846
847             self.context['year'] = tmp_date.year
848             self.context['month'] = tmp_date.month
849             self.context['day'] = tmp_date.day
850             self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
851         except Exception as e:
852             raise ParseException(
853                 f'Invalid nthWeekday expression: {ctx.getText()}'
854             ) from e
855
856     def exitFirstLastWeekdayInMonthMaybeYearExpr(
857         self,
858         ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext,
859     ) -> None:
860         self.exitNthWeekdayInMonthMaybeYearExpr(ctx)
861
862     def exitNth(self, ctx: dateparse_utilsParser.NthContext) -> None:
863         try:
864             i = DateParser._get_int(ctx.getText())
865         except Exception as e:
866             raise ParseException(f'Bad nth expression: {ctx.getText()}') from e
867         else:
868             self.context['nth'] = i
869
870     def exitFirstOrLast(self, ctx: dateparse_utilsParser.FirstOrLastContext) -> None:
871         try:
872             txt = ctx.getText()
873             if txt == 'first':
874                 txt = 1
875             elif txt == 'last':
876                 txt = -1
877             else:
878                 raise ParseException(f'Bad first|last expression: {ctx.getText()}')
879         except Exception as e:
880             raise ParseException(f'Bad first|last expression: {ctx.getText()}') from e
881         else:
882             self.context['nth'] = txt
883
884     def exitDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None:
885         try:
886             dow = ctx.getText().lower()[:3]
887             dow = self.day_name_to_number.get(dow, None)
888         except Exception as e:
889             raise ParseException('Bad day of week') from e
890         else:
891             self.context['dow'] = dow
892
893     def exitDayOfMonth(self, ctx: dateparse_utilsParser.DayOfMonthContext) -> None:
894         try:
895             day = ctx.getText().lower()
896             if day[:3] == 'ide':
897                 self.context['day'] = 'ide'
898                 return
899             if day[:3] == 'non':
900                 self.context['day'] = 'non'
901                 return
902             if day[:3] == 'kal':
903                 self.context['day'] = 1
904                 return
905             day = DateParser._get_int(day)
906             if day < 1 or day > 31:
907                 raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}')
908         except Exception as e:
909             raise ParseException(f'Bad dayOfMonth expression: {ctx.getText()}') from e
910         self.context['day'] = day
911
912     def exitMonthName(self, ctx: dateparse_utilsParser.MonthNameContext) -> None:
913         try:
914             month = ctx.getText()
915             while month[0] == '/' or month[0] == '-':
916                 month = month[1:]
917             month = month[:3].lower()
918             month = self.month_name_to_number.get(month, None)
919             if month is None:
920                 raise ParseException(f'Bad monthName expression: {ctx.getText()}')
921         except Exception as e:
922             raise ParseException(f'Bad monthName expression: {ctx.getText()}') from e
923         else:
924             self.context['month'] = month
925
926     def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None:
927         try:
928             month = DateParser._get_int(ctx.getText())
929             if month < 1 or month > 12:
930                 raise ParseException(f'Bad monthNumber expression: {ctx.getText()}')
931         except Exception as e:
932             raise ParseException(f'Bad monthNumber expression: {ctx.getText()}') from e
933         else:
934             self.context['month'] = month
935
936     def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
937         try:
938             year = DateParser._get_int(ctx.getText())
939             if year < 1:
940                 raise ParseException(f'Bad year expression: {ctx.getText()}')
941         except Exception as e:
942             raise ParseException(f'Bad year expression: {ctx.getText()}') from e
943         else:
944             self.saw_overt_year = True
945             self.context['year'] = year
946
947     def exitSpecialDateMaybeYearExpr(
948         self, ctx: dateparse_utilsParser.SpecialDateMaybeYearExprContext
949     ) -> None:
950         try:
951             special = ctx.specialDate().getText().lower()
952             self.context['special'] = special
953         except Exception as e:
954             raise ParseException(
955                 f'Bad specialDate expression: {ctx.specialDate().getText()}'
956             ) from e
957         try:
958             mod = ctx.thisNextLast()
959             if mod is not None:
960                 if mod.THIS() is not None:
961                     self.context['special_next_last'] = 'this'
962                 elif mod.NEXT() is not None:
963                     self.context['special_next_last'] = 'next'
964                 elif mod.LAST() is not None:
965                     self.context['special_next_last'] = 'last'
966         except Exception as e:
967             raise ParseException(
968                 f'Bad specialDateNextLast expression: {ctx.getText()}'
969             ) from e
970
971     def exitNFoosFromTodayAgoExpr(
972         self, ctx: dateparse_utilsParser.NFoosFromTodayAgoExprContext
973     ) -> None:
974         d = self.now_datetime
975         try:
976             count = DateParser._get_int(ctx.unsignedInt().getText())
977             unit = ctx.deltaUnit().getText().lower()
978             ago_from_now = ctx.AGO_FROM_NOW().getText()
979         except Exception as e:
980             raise ParseException(f'Bad NFoosFromTodayAgoExpr: {ctx.getText()}') from e
981
982         if "ago" in ago_from_now or "back" in ago_from_now:
983             count = -count
984
985         unit = self._figure_out_date_unit(unit)
986         d = n_timeunits_from_base(count, TimeUnit(unit), d)
987         self.context['year'] = d.year
988         self.context['month'] = d.month
989         self.context['day'] = d.day
990
991     def exitDeltaRelativeToTodayExpr(
992         self, ctx: dateparse_utilsParser.DeltaRelativeToTodayExprContext
993     ) -> None:
994         # When someone says "next week" they mean a week from now.
995         # Likewise next month or last year.  These expressions are now
996         # +/- delta.
997         #
998         # But when someone says "this Friday" they mean "this coming
999         # Friday".  It would be weird to say "this Friday" if today
1000         # was already Friday but I'm parsing it to mean: the next day
1001         # that is a Friday.  So when you say "next Friday" you mean
1002         # the Friday after this coming Friday, or 2 Fridays from now.
1003         #
1004         # This set handles this weirdness.
1005         weekdays = set(
1006             [
1007                 TimeUnit.MONDAYS,
1008                 TimeUnit.TUESDAYS,
1009                 TimeUnit.WEDNESDAYS,
1010                 TimeUnit.THURSDAYS,
1011                 TimeUnit.FRIDAYS,
1012                 TimeUnit.SATURDAYS,
1013                 TimeUnit.SUNDAYS,
1014             ]
1015         )
1016         d = self.now_datetime
1017         try:
1018             mod = ctx.thisNextLast()
1019             unit = ctx.deltaUnit().getText().lower()
1020             unit = self._figure_out_date_unit(unit)
1021             if mod.LAST():
1022                 count = -1
1023             elif mod.THIS():
1024                 if unit in weekdays:
1025                     count = +1
1026                 else:
1027                     count = 0
1028             elif mod.NEXT():
1029                 if unit in weekdays:
1030                     count = +2
1031                 else:
1032                     count = +1
1033             else:
1034                 raise ParseException(f'Bad This/Next/Last modifier: {mod}')
1035         except Exception as e:
1036             raise ParseException(
1037                 f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}'
1038             ) from e
1039         d = n_timeunits_from_base(count, TimeUnit(unit), d)
1040         self.context['year'] = d.year
1041         self.context['month'] = d.month
1042         self.context['day'] = d.day
1043
1044     def exitSpecialTimeExpr(
1045         self, ctx: dateparse_utilsParser.SpecialTimeExprContext
1046     ) -> None:
1047         try:
1048             txt = ctx.specialTime().getText().lower()
1049         except Exception as e:
1050             raise ParseException(f'Bad special time expression: {ctx.getText()}') from e
1051         else:
1052             if txt in {'noon', 'midday'}:
1053                 self.context['hour'] = 12
1054                 self.context['minute'] = 0
1055                 self.context['seconds'] = 0
1056                 self.context['micros'] = 0
1057             elif txt == 'midnight':
1058                 self.context['hour'] = 0
1059                 self.context['minute'] = 0
1060                 self.context['seconds'] = 0
1061                 self.context['micros'] = 0
1062             else:
1063                 raise ParseException(f'Bad special time expression: {txt}')
1064
1065         try:
1066             tz = ctx.tzExpr().getText()
1067             self.context['tz'] = DateParser._parse_tz(tz)
1068         except Exception:
1069             pass
1070
1071     def exitTwelveHourTimeExpr(
1072         self, ctx: dateparse_utilsParser.TwelveHourTimeExprContext
1073     ) -> None:
1074         try:
1075             hour = ctx.hour().getText()
1076             while not hour[-1].isdigit():
1077                 hour = hour[:-1]
1078             hour = DateParser._get_int(hour)
1079         except Exception as e:
1080             raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e
1081         if hour <= 0 or hour > 12:
1082             raise ParseException(f'Bad hour (out of range): {hour}')
1083
1084         try:
1085             minute = DateParser._get_int(ctx.minute().getText())
1086         except Exception:
1087             minute = 0
1088         if minute < 0 or minute > 59:
1089             raise ParseException(f'Bad minute (out of range): {minute}')
1090         self.context['minute'] = minute
1091
1092         try:
1093             seconds = DateParser._get_int(ctx.second().getText())
1094         except Exception:
1095             seconds = 0
1096         if seconds < 0 or seconds > 59:
1097             raise ParseException(f'Bad second (out of range): {seconds}')
1098         self.context['seconds'] = seconds
1099
1100         try:
1101             micros = DateParser._get_int(ctx.micros().getText())
1102         except Exception:
1103             micros = 0
1104         if micros < 0 or micros > 1000000:
1105             raise ParseException(f'Bad micros (out of range): {micros}')
1106         self.context['micros'] = micros
1107
1108         try:
1109             ampm = ctx.ampm().getText()
1110         except Exception as e:
1111             raise ParseException(f'Bad ampm: {ctx.ampm().getText()}') from e
1112         if hour == 12:
1113             hour = 0
1114         if ampm[0] == 'p':
1115             hour += 12
1116         self.context['hour'] = hour
1117
1118         try:
1119             tz = ctx.tzExpr().getText()
1120             self.context['tz'] = DateParser._parse_tz(tz)
1121         except Exception:
1122             pass
1123
1124     def exitTwentyFourHourTimeExpr(
1125         self, ctx: dateparse_utilsParser.TwentyFourHourTimeExprContext
1126     ) -> None:
1127         try:
1128             hour = ctx.hour().getText()
1129             while not hour[-1].isdigit():
1130                 hour = hour[:-1]
1131             hour = DateParser._get_int(hour)
1132         except Exception as e:
1133             raise ParseException(f'Bad hour: {ctx.hour().getText()}') from e
1134         if hour < 0 or hour > 23:
1135             raise ParseException(f'Bad hour (out of range): {hour}')
1136         self.context['hour'] = hour
1137
1138         try:
1139             minute = DateParser._get_int(ctx.minute().getText())
1140         except Exception:
1141             minute = 0
1142         if minute < 0 or minute > 59:
1143             raise ParseException(f'Bad minute (out of range): {ctx.getText()}')
1144         self.context['minute'] = minute
1145
1146         try:
1147             seconds = DateParser._get_int(ctx.second().getText())
1148         except Exception:
1149             seconds = 0
1150         if seconds < 0 or seconds > 59:
1151             raise ParseException(f'Bad second (out of range): {ctx.getText()}')
1152         self.context['seconds'] = seconds
1153
1154         try:
1155             micros = DateParser._get_int(ctx.micros().getText())
1156         except Exception:
1157             micros = 0
1158         if micros < 0 or micros >= 1000000:
1159             raise ParseException(f'Bad micros (out of range): {ctx.getText()}')
1160         self.context['micros'] = micros
1161
1162         try:
1163             tz = ctx.tzExpr().getText()
1164             self.context['tz'] = DateParser._parse_tz(tz)
1165         except Exception:
1166             pass
1167
1168
1169 @bootstrap.initialize
1170 def main() -> None:
1171     parser = DateParser()
1172     for line in sys.stdin:
1173         line = line.strip()
1174         line = re.sub(r"#.*$", "", line)
1175         if re.match(r"^ *$", line) is not None:
1176             continue
1177         try:
1178             dt = parser.parse(line)
1179         except Exception:
1180             logger.exception("Could not parse supposed date expression: %s", line)
1181             print("Unrecognized.")
1182         else:
1183             assert dt is not None
1184             print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)'))
1185     sys.exit(0)
1186
1187
1188 if __name__ == "__main__":
1189     main()