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