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