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