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