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