#!/usr/bin/env python3
# type: ignore
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-instance-attributes
# © Copyright 2021-2023, Scott Gasch
"""
Parse dates / datetimes in a variety of formats. Some examples:
| today
| tomorrow
| yesterday
| 21:30
| 12:01am
| 12:01pm
| last Wednesday
| this Wednesday
| next Wed
| this coming Tues
| this past Mon
| 4 days ago
| 4 Mondays ago
| 4 months ago
| 3 days back
| 13 weeks from now
| 1 year from now
| 4 weeks from now
| 3 saturdays ago
| 4 months from today
| 5 years from yesterday
| 6 weeks from tomorrow
| april 15, 2005
| april 21
| 9:30am on last Wednesday
| 2005/apr/15
| 2005 apr 15
| the 1st wednesday in may
| the last sun of june
| this easter
| last xmas
| Christmas, 1999
| next MLK day
| Halloween, 2020
| 5 work days after independence day
| 50 working days from last wed
| 25 working days before xmas
| today +1 week
| sunday -3 weeks
| 3 weeks before xmas, 1999
| 3 days before new years eve, 2000
| july 4th
| the ides of march
| the nones of april
| the kalends of may
| 4 sundays before veterans' day
| xmas eve
| this friday at 5pm
| presidents day
| memorial day, 1921
| thanksgiving
| 2 sun in jun
| easter -40 days
| 2 days before last xmas at 3:14:15.92a
| 3 weeks after xmas, 1995 at midday
| 4 months before easter, 1992 at midnight
| 5 months before halloween, 1995 at noon
| 4 days before last wednesday
| 44 months after today
| 44 years before today
| 44 weeks ago
| 15 minutes to 3am
| quarter past 4pm
| half past 9
| 4 seconds to midnight
| 4 seconds to midnight, tomorrow
| 2021/apr/15T21:30:44.55
| 2021/apr/15 at 21:30:44.55
| 2021/4/15 at 21:30:44.55
| 2021/04/15 at 21:30:44.55Z
| 2021/04/15 at 21:30:44.55EST
| 13 days after last memorial day at 12 seconds before 4pm
This code is used by other code in the pyutils library such as
:meth:`pyutils.argparse_utils.valid_datetime`,
:meth:`pyutils.argparse_utils.valid_date`,
:meth:`pyutils.string_utils.to_datetime`
and
:meth:`pyutils.string_utils.to_date`. This means any of these are
also able to accept and recognize this larger set of date expressions.
See the `unittest <https://wannabe.guru.org/gitweb/?p=pyutils.git;a=blob_plain;f=tests/datetimes/dateparse_utils_test.py;h=93c7b96e4c19af217fbafcf1ed5dbde13ec599c5;hb=HEAD>`_ for more examples and the `grammar <https://wannabe.guru.org/gitweb/?p=pyutils.git;a=blob_plain;f=src/pyutils/datetimes/dateparse_utils.g4;hb=HEAD>`_ for more details.
"""
import datetime
import functools
import logging
import re
import sys
from typing import Any, Callable, Dict, Optional
import antlr4 # type: ignore
import holidays # type: ignore
import pytz
from pyutils import bootstrap, decorator_utils
from pyutils.datetimes.dateparse_utilsLexer import dateparse_utilsLexer # type: ignore
from pyutils.datetimes.dateparse_utilsListener import (
dateparse_utilsListener,
) # type: ignore
from pyutils.datetimes.dateparse_utilsParser import (
dateparse_utilsParser,
) # type: ignore
from pyutils.datetimes.datetime_utils import (
TimeUnit,
date_to_datetime,
datetime_to_date,
easter,
n_timeunits_from_base,
)
from pyutils.exceptions import PyUtilsDateParseException
from pyutils.security import acl
logger = logging.getLogger(__name__)
def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
@functools.wraps(enter_or_exit_f)
def debug_parse_wrapper(*args, **kwargs):
# slf = args[0]
ctx = args[1]
depth = ctx.depth()
logger.debug(
' ' * (depth - 1)
+ f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
)
for c in ctx.getChildren():
logger.debug(' ' * (depth - 1) + f'{c} {type(c)}')
retval = enter_or_exit_f(*args, **kwargs)
return retval
return debug_parse_wrapper
class RaisingErrorListener(antlr4.DiagnosticErrorListener):
"""An error listener that raises PyUtilsDateParseExceptions.
Raises:
PyUtilsDateParseException: on parse error
"""
def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
raise PyUtilsDateParseException(msg)
def reportAmbiguity(
self, recognizer, dfa, startIndex, stopIndex, exact, ambigAlts, configs
):
pass
def reportAttemptingFullContext(
self, recognizer, dfa, startIndex, stopIndex, conflictingAlts, configs
):
pass
def reportContextSensitivity(
self, recognizer, dfa, startIndex, stopIndex, prediction, configs
):
pass
[docs]@decorator_utils.decorate_matching_methods_with(
debug_parse,
acl=acl.StringWildcardBasedACL(
allowed_patterns=[
'enter*',
'exit*',
],
denied_patterns=['enterEveryRule', 'exitEveryRule'],
order_to_check_allow_deny=acl.Order.DENY_ALLOW,
default_answer=False,
),
)
class DateParser(dateparse_utilsListener):
"""A class to parse dates expressed in human language (English).
Example usage::
d = DateParser()
d.parse('next wednesday')
dt = d.get_datetime()
print(dt)
Wednesday 2022/10/26 00:00:00.000000
Note that the interface is somewhat klunky here because this class is
conforming to interfaces auto-generated by ANTLR as it parses the grammar.
See also :meth:`pyutils.string_utils.to_date`.
"""
PARSE_TYPE_SINGLE_DATE_EXPR = 1
PARSE_TYPE_BASE_AND_OFFSET_EXPR = 2
PARSE_TYPE_SINGLE_TIME_EXPR = 3
PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR = 4
def __init__(self, *, override_now_for_test_purposes=None) -> None:
"""Construct a parser.
Args:
override_now_for_test_purposes: passing a value to
override_now_for_test_purposes can be used to force
this parser instance to use a custom date/time for its
idea of "now" so that the code can be more easily
unittested. Leave as None for real use cases.
"""
self.month_name_to_number = {
'jan': 1,
'feb': 2,
'mar': 3,
'apr': 4,
'may': 5,
'jun': 6,
'jul': 7,
'aug': 8,
'sep': 9,
'oct': 10,
'nov': 11,
'dec': 12,
}
# Used only for ides/nones. Month length on a non-leap year.
self.typical_days_per_month = {
1: 31,
2: 28,
3: 31,
4: 30,
5: 31,
6: 30,
7: 31,
8: 31,
9: 30,
10: 31,
11: 30,
12: 31,
}
# N.B. day number is also synched with datetime_utils.TimeUnit values
# which allows expressions like "3 wednesdays from now" to work.
self.day_name_to_number = {
'mon': 0,
'tue': 1,
'wed': 2,
'thu': 3,
'fri': 4,
'sat': 5,
'sun': 6,
}
# These TimeUnits are defined in datetime_utils and are used as params
# to datetime_utils.n_timeunits_from_base.
self.time_delta_unit_to_constant = {
'hou': TimeUnit.HOURS,
'min': TimeUnit.MINUTES,
'sec': TimeUnit.SECONDS,
}
self.delta_unit_to_constant = {
'day': TimeUnit.DAYS,
'wor': TimeUnit.WORKDAYS,
'wee': TimeUnit.WEEKS,
'mon': TimeUnit.MONTHS,
'yea': TimeUnit.YEARS,
}
self.override_now_for_test_purposes = override_now_for_test_purposes
# Note: _reset defines several class fields. It is used both here
# in the c'tor but also in between parse operations to restore the
# class' state and allow it to be reused.
#
self._reset()
[docs] def parse(self, date_string: str) -> Optional[datetime.datetime]:
"""
Parse a ~free form date/time expression and return a
timezone agnostic datetime on success. Also sets
`self.datetime`, `self.date` and `self.time` which can each be
accessed other methods on the class: :meth:`get_datetime`,
:meth:`get_date` and :meth:`get_time`. Raises a
`PyUtilsDateParseException` with a helpful(?) message on parse error or
confusion.
This is the main entrypoint to this class for caller code.
To get an idea of what expressions can be parsed, check out
the unittest and the grammar.
Args:
date_string: the string to parse
Returns:
A datetime.datetime representing the parsed date/time or
None on error.
Raises:
PyUtilsDateParseException: an exception happened during parsing
.. note::
Parsed date expressions without any time part return
hours = minutes = seconds = microseconds = 0 (i.e. at
midnight that day). Parsed time expressions without any
date part default to date = today.
Example usage::
txt = '3 weeks before last tues at 9:15am'
dp = DateParser()
dt1 = dp.parse(txt)
dt2 = dp.get_datetime(tz=pytz.timezone('US/Pacific'))
# dt1 and dt2 will be identical other than the fact that
# the latter's tzinfo will be set to PST/PDT.
"""
date_string = date_string.strip()
date_string = re.sub(r'\s+', ' ', date_string)
self._reset()
listener = RaisingErrorListener()
input_stream = antlr4.InputStream(date_string)
lexer = dateparse_utilsLexer(input_stream)
lexer.removeErrorListeners()
lexer.addErrorListener(listener)
stream = antlr4.CommonTokenStream(lexer)
parser = dateparse_utilsParser(stream)
parser.removeErrorListeners()
parser.addErrorListener(listener)
tree = parser.parse()
walker = antlr4.ParseTreeWalker()
walker.walk(self, tree)
return self.datetime
[docs] def get_date(self) -> Optional[datetime.date]:
"""
Returns:
The date part of the last :meth:`parse` operation again
or None.
"""
return self.date
[docs] def get_time(self) -> Optional[datetime.time]:
"""
Returns:
The time part of the last :meth:`parse` operation again
or None.
"""
return self.time
[docs] def get_datetime(
self, *, tz: Optional[datetime.tzinfo] = None
) -> Optional[datetime.datetime]:
"""Get the datetime of the last :meth:`parse` operation again
ot None.
Args:
tz: the timezone to set on output times. By default we
return timezone-naive datetime objects.
Returns:
the same datetime that :meth:`parse` last did, optionally
overriding the timezone. Returns None of no calls to
:meth:`parse` have yet been made.
.. note::
Parsed date expressions without any time part return
hours = minutes = seconds = microseconds = 0 (i.e. at
midnight that day). Parsed time expressions without any
date part default to date = today.
"""
dt = self.datetime
if dt is not None:
if tz is not None:
dt = dt.replace(tzinfo=None).astimezone(tz=tz)
return dt
# -- helpers --
def _reset(self):
"""Reset at init and between parses."""
if self.override_now_for_test_purposes is None:
self.now_datetime = datetime.datetime.now()
self.today = datetime.date.today()
else:
self.now_datetime = self.override_now_for_test_purposes
self.today = datetime_to_date(self.override_now_for_test_purposes)
self.date: Optional[datetime.date] = None
self.time: Optional[datetime.time] = None
self.datetime: Optional[datetime.datetime] = None
self.context: Dict[str, Any] = {}
self.timedelta = datetime.timedelta(seconds=0)
self.saw_overt_year = False
@staticmethod
def _normalize_special_day_name(name: str) -> str:
"""String normalization / canonicalization for date expressions."""
name = name.lower()
name = name.replace("'", '')
name = name.replace('xmas', 'christmas')
name = name.replace('mlk', 'martin luther king')
name = name.replace(' ', '')
eve = 'eve' if name[-3:] == 'eve' else ''
name = name[:5] + eve
name = name.replace('washi', 'presi')
return name
def _figure_out_date_unit(self, orig: str) -> TimeUnit:
"""Figure out what unit a date expression piece is talking about."""
if 'month' in orig:
return TimeUnit.MONTHS
txt = orig.lower()[:3]
if txt in self.day_name_to_number:
return TimeUnit(self.day_name_to_number[txt])
elif txt in self.delta_unit_to_constant:
return TimeUnit(self.delta_unit_to_constant[txt])
raise PyUtilsDateParseException(f'Invalid date unit: {orig}')
def _figure_out_time_unit(self, orig: str) -> int:
"""Figure out what unit a time expression piece is talking about."""
txt = orig.lower()[:3]
if txt in self.time_delta_unit_to_constant:
return self.time_delta_unit_to_constant[txt]
raise PyUtilsDateParseException(f'Invalid time unit: {orig}')
def _parse_special_date(self, name: str) -> Optional[datetime.date]:
"""Parse what we think is a special date name and return its datetime
(or None if it can't be parsed).
"""
today = self.today
year = self.context.get('year', today.year)
name = DateParser._normalize_special_day_name(self.context['special'])
# Yesterday, today, tomorrow -- ignore any next/last
if name in {'today', 'now'}:
return today
if name == 'yeste':
return today + datetime.timedelta(days=-1)
if name == 'tomor':
return today + datetime.timedelta(days=+1)
next_last = self.context.get('special_next_last', '')
if next_last == 'next':
year += 1
self.saw_overt_year = True
elif next_last == 'last':
year -= 1
self.saw_overt_year = True
# Holiday names
if name == 'easte':
return easter(year=year)
elif name == 'hallo':
return datetime.date(year=year, month=10, day=31)
for holiday_date, holiday_name in sorted(holidays.US(years=year).items()):
if 'Observed' not in holiday_name:
holiday_name = DateParser._normalize_special_day_name(holiday_name)
if name == holiday_name:
return holiday_date
if name == 'chriseve':
return datetime.date(year=year, month=12, day=24)
elif name == 'newyeeve':
return datetime.date(year=year, month=12, day=31)
return None
def _resolve_ides_nones(self, day: str, month_number: int) -> int:
"""Handle date expressions like "the ides of March" which require
both the "ides" and the month since the definition of the "ides"
changes based on the length of the month.
"""
assert 'ide' in day or 'non' in day
assert month_number in self.typical_days_per_month
typical_days_per_month = self.typical_days_per_month[month_number]
# "full" month
if typical_days_per_month == 31:
if self.context['day'] == 'ide':
return 15
else:
return 7
# "hollow" month
else:
if self.context['day'] == 'ide':
return 13
else:
return 5
def _parse_normal_date(self) -> datetime.date:
if 'dow' in self.context and 'month' not in self.context:
d = self.today
while d.weekday() != self.context['dow']:
d += datetime.timedelta(days=1)
return d
if 'month' not in self.context:
raise PyUtilsDateParseException('Missing month')
if 'day' not in self.context:
raise PyUtilsDateParseException('Missing day')
if 'year' not in self.context:
self.context['year'] = self.today.year
self.saw_overt_year = False
else:
self.saw_overt_year = True
# Handling "ides" and "nones" requires both the day and month.
if self.context['day'] == 'ide' or self.context['day'] == 'non':
self.context['day'] = self._resolve_ides_nones(
self.context['day'], self.context['month']
)
return datetime.date(
year=self.context['year'],
month=self.context['month'],
day=self.context['day'],
)
@staticmethod
def _parse_tz(txt: str) -> Any:
if txt == 'Z':
txt = 'UTC'
# Try pytz
try:
tz1 = pytz.timezone(txt)
if tz1 is not None:
return tz1
except pytz.exceptions.UnknownTimeZoneError:
pass
# Try constructing an offset in seconds
try:
txt_sign = txt[0]
if txt_sign in {'-', '+'}:
sign = +1 if txt_sign == '+' else -1
hour = int(txt[1:3])
minute = int(txt[-2:])
offset = sign * (hour * 60 * 60) + sign * (minute * 60)
tzoffset = datetime.timezone(datetime.timedelta(seconds=offset))
return tzoffset
except ValueError:
pass
return None
@staticmethod
def _get_int(txt: str) -> int:
while not txt[0].isdigit() and txt[0] != '-' and txt[0] != '+':
txt = txt[1:]
while not txt[-1].isdigit():
txt = txt[:-1]
return int(txt)
# -- overridden methods invoked by parse walk. Note: not part of the class'
# public API(!!) --
def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
pass
def visitTerminal(self, node: antlr4.TerminalNode) -> None:
pass
def exitParse(self, ctx: dateparse_utilsParser.ParseContext) -> None:
"""Populate self.datetime."""
if self.date is None:
self.date = self.today
year = self.date.year
month = self.date.month
day = self.date.day
if self.time is None:
self.time = datetime.time(0, 0, 0)
hour = self.time.hour
minute = self.time.minute
second = self.time.second
micros = self.time.microsecond
self.datetime = datetime.datetime(
year,
month,
day,
hour,
minute,
second,
micros,
tzinfo=self.time.tzinfo,
)
# Apply resudual adjustments to times here when we have a
# datetime.
self.datetime = self.datetime + self.timedelta
assert self.datetime is not None
self.time = datetime.time(
self.datetime.hour,
self.datetime.minute,
self.datetime.second,
self.datetime.microsecond,
self.datetime.tzinfo,
)
def enterDateExpr(self, ctx: dateparse_utilsParser.DateExprContext):
self.date = None
if ctx.singleDateExpr() is not None:
self.main_type = DateParser.PARSE_TYPE_SINGLE_DATE_EXPR
elif ctx.baseAndOffsetDateExpr() is not None:
self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
def enterTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext):
self.time = None
if ctx.singleTimeExpr() is not None:
self.time_type = DateParser.PARSE_TYPE_SINGLE_TIME_EXPR
elif ctx.baseAndOffsetTimeExpr() is not None:
self.time_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_TIME_EXPR
def exitDateExpr(self, ctx: dateparse_utilsParser.DateExprContext) -> None:
"""When we leave the date expression, populate self.date."""
if 'special' in self.context:
self.date = self._parse_special_date(self.context['special'])
else:
self.date = self._parse_normal_date()
assert self.date is not None
# For a single date, just return the date we pulled out.
if self.main_type == DateParser.PARSE_TYPE_SINGLE_DATE_EXPR:
return
# Otherwise treat self.date as a base date that we're modifying
# with an offset.
if 'delta_int' not in self.context:
raise PyUtilsDateParseException('Missing delta_int?!')
count = self.context['delta_int']
if count == 0:
return
# Adjust count's sign based on the presence of 'before' or 'after'.
if 'delta_before_after' in self.context:
before_after = self.context['delta_before_after'].lower()
if before_after in {'before', 'until', 'til', 'to'}:
count = -count
# What are we counting units of?
if 'delta_unit' not in self.context:
raise PyUtilsDateParseException('Missing delta_unit?!')
unit = self.context['delta_unit']
dt = n_timeunits_from_base(count, TimeUnit(unit), date_to_datetime(self.date))
self.date = datetime_to_date(dt)
def exitTimeExpr(self, ctx: dateparse_utilsParser.TimeExprContext) -> None:
# Simple time?
self.time = datetime.time(
self.context['hour'],
self.context['minute'],
self.context['seconds'],
self.context['micros'],
tzinfo=self.context.get('tz', None),
)
if self.time_type == DateParser.PARSE_TYPE_SINGLE_TIME_EXPR:
return
# If we get here there (should be) a relative adjustment to
# the time.
if 'nth' in self.context:
count = self.context['nth']
elif 'time_delta_int' in self.context:
count = self.context['time_delta_int']
else:
raise PyUtilsDateParseException('Missing delta in relative time.')
if count == 0:
return
# Adjust count's sign based on the presence of 'before' or 'after'.
if 'time_delta_before_after' in self.context:
before_after = self.context['time_delta_before_after'].lower()
if before_after in {'before', 'until', 'til', 'to'}:
count = -count
# What are we counting units of... assume minutes.
if 'time_delta_unit' not in self.context:
self.timedelta += datetime.timedelta(minutes=count)
else:
unit = self.context['time_delta_unit']
if unit == TimeUnit.SECONDS:
self.timedelta += datetime.timedelta(seconds=count)
elif unit == TimeUnit.MINUTES:
self.timedelta = datetime.timedelta(minutes=count)
elif unit == TimeUnit.HOURS:
self.timedelta = datetime.timedelta(hours=count)
else:
raise PyUtilsDateParseException(f'Invalid Unit: "{unit}"')
def exitDeltaPlusMinusExpr(
self, ctx: dateparse_utilsParser.DeltaPlusMinusExprContext
) -> None:
try:
n = ctx.nth()
if n is None:
raise PyUtilsDateParseException(
f'Bad N in Delta +/- Expr: {ctx.getText()}'
)
n = n.getText()
n = DateParser._get_int(n)
unit = self._figure_out_date_unit(ctx.deltaUnit().getText().lower())
except Exception as e:
raise PyUtilsDateParseException(
f'Invalid Delta +/-: {ctx.getText()}'
) from e
else:
self.context['delta_int'] = n
self.context['delta_unit'] = unit
def exitNextLastUnit(self, ctx: dateparse_utilsParser.DeltaUnitContext) -> None:
try:
unit = self._figure_out_date_unit(ctx.getText().lower())
except Exception as e:
raise PyUtilsDateParseException(f'Bad delta unit: {ctx.getText()}') from e
else:
self.context['delta_unit'] = unit
def exitDeltaNextLast(
self, ctx: dateparse_utilsParser.DeltaNextLastContext
) -> None:
try:
txt = ctx.getText().lower()
except Exception as e:
raise PyUtilsDateParseException(f'Bad next/last: {ctx.getText()}') from e
if 'month' in self.context or 'day' in self.context or 'year' in self.context:
raise PyUtilsDateParseException(
'Next/last expression expected to be relative to today.'
)
if txt[:4] == 'next':
self.context['delta_int'] = +1
self.context['day'] = self.now_datetime.day
self.context['month'] = self.now_datetime.month
self.context['year'] = self.now_datetime.year
self.saw_overt_year = True
elif txt[:4] == 'last':
self.context['delta_int'] = -1
self.context['day'] = self.now_datetime.day
self.context['month'] = self.now_datetime.month
self.context['year'] = self.now_datetime.year
self.saw_overt_year = True
else:
raise PyUtilsDateParseException(f'Bad next/last: {ctx.getText()}')
def exitCountUnitsBeforeAfterTimeExpr(
self, ctx: dateparse_utilsParser.CountUnitsBeforeAfterTimeExprContext
) -> None:
if 'nth' not in self.context:
raise PyUtilsDateParseException(f'Bad count expression: {ctx.getText()}')
try:
unit = self._figure_out_time_unit(ctx.deltaTimeUnit().getText().lower())
self.context['time_delta_unit'] = unit
except Exception as e:
raise PyUtilsDateParseException(f'Bad delta unit: {ctx.getText()}') from e
if 'time_delta_before_after' not in self.context:
raise PyUtilsDateParseException(f'Bad Before/After: {ctx.getText()}')
def exitDeltaTimeFraction(
self, ctx: dateparse_utilsParser.DeltaTimeFractionContext
) -> None:
try:
txt = ctx.getText().lower()[:4]
if txt == 'quar':
self.context['time_delta_int'] = 15
self.context['time_delta_unit'] = TimeUnit.MINUTES
elif txt == 'half':
self.context['time_delta_int'] = 30
self.context['time_delta_unit'] = TimeUnit.MINUTES
else:
raise PyUtilsDateParseException(f'Bad time fraction {ctx.getText()}')
except Exception as e:
raise PyUtilsDateParseException(f'Bad time fraction {ctx.getText()}') from e
def exitDeltaBeforeAfter(
self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
) -> None:
try:
txt = ctx.getText().lower()
except Exception as e:
raise PyUtilsDateParseException(
f'Bad delta before|after: {ctx.getText()}'
) from e
else:
self.context['delta_before_after'] = txt
def exitDeltaTimeBeforeAfter(
self, ctx: dateparse_utilsParser.DeltaBeforeAfterContext
) -> None:
try:
txt = ctx.getText().lower()
except Exception as e:
raise PyUtilsDateParseException(
f'Bad delta before|after: {ctx.getText()}'
) from e
else:
self.context['time_delta_before_after'] = txt
def exitNthWeekdayInMonthMaybeYearExpr(
self, ctx: dateparse_utilsParser.NthWeekdayInMonthMaybeYearExprContext
) -> None:
"""Do a bunch of work to convert expressions like...
'the 2nd Friday of June' -and-
'the last Wednesday in October'
...into base + offset expressions instead.
"""
try:
if 'nth' not in self.context:
raise PyUtilsDateParseException(f'Missing nth number: {ctx.getText()}')
n = self.context['nth']
if n < 1 or n > 5: # months never have more than 5 Foodays
if n != -1:
raise PyUtilsDateParseException(
f'Invalid nth number: {ctx.getText()}'
)
del self.context['nth']
self.context['delta_int'] = n
year = self.context.get('year', self.today.year)
if 'month' not in self.context:
raise PyUtilsDateParseException(
f'Missing month expression: {ctx.getText()}'
)
month = self.context['month']
dow = self.context['dow']
del self.context['dow']
self.context['delta_unit'] = dow
# For the nth Fooday in Month, start at the last day of
# the previous month count ahead N Foodays. For the last
# Fooday in Month, start at the last of the month and
# count back one Fooday.
if n == -1:
month += 1
if month == 13:
month = 1
year += 1
tmp_date = datetime.date(year=year, month=month, day=1)
tmp_date = tmp_date - datetime.timedelta(days=1)
# The delta adjustment code can handle the case where
# the last day of the month is the day we're looking
# for already.
else:
tmp_date = datetime.date(year=year, month=month, day=1)
tmp_date = tmp_date - datetime.timedelta(days=1)
self.context['year'] = tmp_date.year
self.context['month'] = tmp_date.month
self.context['day'] = tmp_date.day
self.main_type = DateParser.PARSE_TYPE_BASE_AND_OFFSET_EXPR
except Exception as e:
raise PyUtilsDateParseException(
f'Invalid nthWeekday expression: {ctx.getText()}'
) from e
def exitFirstLastWeekdayInMonthMaybeYearExpr(
self,
ctx: dateparse_utilsParser.FirstLastWeekdayInMonthMaybeYearExprContext,
) -> None:
self.exitNthWeekdayInMonthMaybeYearExpr(ctx)
def exitNth(self, ctx: dateparse_utilsParser.NthContext) -> None:
try:
i = DateParser._get_int(ctx.getText())
except Exception as e:
raise PyUtilsDateParseException(
f'Bad nth expression: {ctx.getText()}'
) from e
else:
self.context['nth'] = i
def exitFirstOrLast(self, ctx: dateparse_utilsParser.FirstOrLastContext) -> None:
try:
txt = ctx.getText()
if txt == 'first':
txt = 1
elif txt == 'last':
txt = -1
else:
raise PyUtilsDateParseException(
f'Bad first|last expression: {ctx.getText()}'
)
except Exception as e:
raise PyUtilsDateParseException(
f'Bad first|last expression: {ctx.getText()}'
) from e
else:
self.context['nth'] = txt
def exitDayName(self, ctx: dateparse_utilsParser.DayNameContext) -> None:
try:
dow = ctx.getText().lower()[:3]
dow = self.day_name_to_number.get(dow, None)
except Exception as e:
raise PyUtilsDateParseException('Bad day of week') from e
else:
self.context['dow'] = dow
def exitDayOfMonth(self, ctx: dateparse_utilsParser.DayOfMonthContext) -> None:
try:
day = ctx.getText().lower()
if day[:3] == 'ide':
self.context['day'] = 'ide'
return
if day[:3] == 'non':
self.context['day'] = 'non'
return
if day[:3] == 'kal':
self.context['day'] = 1
return
day = DateParser._get_int(day)
if day < 1 or day > 31:
raise PyUtilsDateParseException(
f'Bad dayOfMonth expression: {ctx.getText()}'
)
except Exception as e:
raise PyUtilsDateParseException(
f'Bad dayOfMonth expression: {ctx.getText()}'
) from e
self.context['day'] = day
def exitMonthName(self, ctx: dateparse_utilsParser.MonthNameContext) -> None:
try:
month = ctx.getText()
while month[0] == '/' or month[0] == '-':
month = month[1:]
month = month[:3].lower()
month = self.month_name_to_number.get(month, None)
if month is None:
raise PyUtilsDateParseException(
f'Bad monthName expression: {ctx.getText()}'
)
except Exception as e:
raise PyUtilsDateParseException(
f'Bad monthName expression: {ctx.getText()}'
) from e
else:
self.context['month'] = month
def exitMonthNumber(self, ctx: dateparse_utilsParser.MonthNumberContext) -> None:
try:
month = DateParser._get_int(ctx.getText())
if month < 1 or month > 12:
raise PyUtilsDateParseException(
f'Bad monthNumber expression: {ctx.getText()}'
)
except Exception as e:
raise PyUtilsDateParseException(
f'Bad monthNumber expression: {ctx.getText()}'
) from e
else:
self.context['month'] = month
def exitYear(self, ctx: dateparse_utilsParser.YearContext) -> None:
try:
year = DateParser._get_int(ctx.getText())
if year < 1:
raise PyUtilsDateParseException(f'Bad year expression: {ctx.getText()}')
except Exception as e:
raise PyUtilsDateParseException(
f'Bad year expression: {ctx.getText()}'
) from e
else:
self.saw_overt_year = True
self.context['year'] = year
def exitSpecialDateMaybeYearExpr(
self, ctx: dateparse_utilsParser.SpecialDateMaybeYearExprContext
) -> None:
try:
special = ctx.specialDate().getText().lower()
self.context['special'] = special
except Exception as e:
raise PyUtilsDateParseException(
f'Bad specialDate expression: {ctx.specialDate().getText()}'
) from e
try:
mod = ctx.thisNextLast()
if mod is not None:
if mod.THIS() is not None:
self.context['special_next_last'] = 'this'
elif mod.NEXT() is not None:
self.context['special_next_last'] = 'next'
elif mod.LAST() is not None:
self.context['special_next_last'] = 'last'
except Exception as e:
raise PyUtilsDateParseException(
f'Bad specialDateNextLast expression: {ctx.getText()}'
) from e
def exitNFoosFromTodayAgoExpr(
self, ctx: dateparse_utilsParser.NFoosFromTodayAgoExprContext
) -> None:
d = self.now_datetime
try:
count = DateParser._get_int(ctx.unsignedInt().getText())
unit = ctx.deltaUnit().getText().lower()
ago_from_now = ctx.AGO_FROM_NOW().getText()
except Exception as e:
raise PyUtilsDateParseException(
f'Bad NFoosFromTodayAgoExpr: {ctx.getText()}'
) from e
if "ago" in ago_from_now or "back" in ago_from_now:
count = -count
unit = self._figure_out_date_unit(unit)
d = n_timeunits_from_base(count, TimeUnit(unit), d)
self.context['year'] = d.year
self.context['month'] = d.month
self.context['day'] = d.day
def exitDeltaRelativeToTodayExpr(
self, ctx: dateparse_utilsParser.DeltaRelativeToTodayExprContext
) -> None:
# When someone says "next week" they mean a week from now.
# Likewise next month or last year. These expressions are now
# +/- delta.
#
# But when someone says "this Friday" they mean "this coming
# Friday". It would be weird to say "this Friday" if today
# was already Friday but I'm parsing it to mean: the next day
# that is a Friday. So when you say "next Friday" you mean
# the Friday after this coming Friday, or 2 Fridays from now.
#
# This set handles this weirdness.
weekdays = set(
[
TimeUnit.MONDAYS,
TimeUnit.TUESDAYS,
TimeUnit.WEDNESDAYS,
TimeUnit.THURSDAYS,
TimeUnit.FRIDAYS,
TimeUnit.SATURDAYS,
TimeUnit.SUNDAYS,
]
)
d = self.now_datetime
try:
mod = ctx.thisNextLast()
unit = ctx.deltaUnit().getText().lower()
unit = self._figure_out_date_unit(unit)
if mod.LAST():
count = -1
elif mod.THIS():
if unit in weekdays:
count = +1
else:
count = 0
elif mod.NEXT():
if unit in weekdays:
count = +2
else:
count = +1
else:
raise PyUtilsDateParseException(f'Bad This/Next/Last modifier: {mod}')
except Exception as e:
raise PyUtilsDateParseException(
f'Bad DeltaRelativeToTodayExpr: {ctx.getText()}'
) from e
d = n_timeunits_from_base(count, TimeUnit(unit), d)
self.context['year'] = d.year
self.context['month'] = d.month
self.context['day'] = d.day
def exitSpecialTimeExpr(
self, ctx: dateparse_utilsParser.SpecialTimeExprContext
) -> None:
try:
txt = ctx.specialTime().getText().lower()
except Exception as e:
raise PyUtilsDateParseException(
f'Bad special time expression: {ctx.getText()}'
) from e
else:
if txt in {'noon', 'midday'}:
self.context['hour'] = 12
self.context['minute'] = 0
self.context['seconds'] = 0
self.context['micros'] = 0
elif txt == 'midnight':
self.context['hour'] = 0
self.context['minute'] = 0
self.context['seconds'] = 0
self.context['micros'] = 0
else:
raise PyUtilsDateParseException(f'Bad special time expression: {txt}')
if ctx.tzExpr():
tz = ctx.tzExpr().getText()
self.context['tz'] = DateParser._parse_tz(tz)
def exitTwelveHourTimeExpr(
self, ctx: dateparse_utilsParser.TwelveHourTimeExprContext
) -> None:
try:
hour = ctx.hour().getText()
while not hour[-1].isdigit():
hour = hour[:-1]
hour = DateParser._get_int(hour)
except Exception as e:
raise PyUtilsDateParseException(f'Bad hour: {ctx.hour().getText()}') from e
if hour <= 0 or hour > 12:
raise PyUtilsDateParseException(f'Bad hour (out of range): {hour}')
try:
minute = DateParser._get_int(ctx.minute().getText())
except Exception:
minute = 0
if minute < 0 or minute > 59:
raise PyUtilsDateParseException(f'Bad minute (out of range): {minute}')
self.context['minute'] = minute
try:
seconds = DateParser._get_int(ctx.second().getText())
except Exception:
seconds = 0
if seconds < 0 or seconds > 59:
raise PyUtilsDateParseException(f'Bad second (out of range): {seconds}')
self.context['seconds'] = seconds
try:
micros = DateParser._get_int(ctx.micros().getText())
except Exception:
micros = 0
if micros < 0 or micros > 1000000:
raise PyUtilsDateParseException(f'Bad micros (out of range): {micros}')
self.context['micros'] = micros
try:
ampm = ctx.ampm().getText()
except Exception as e:
raise PyUtilsDateParseException(f'Bad ampm: {ctx.ampm().getText()}') from e
if hour == 12:
hour = 0
if ampm[0] == 'p':
hour += 12
self.context['hour'] = hour
if ctx.tzExpr():
tz = ctx.tzExpr().getText()
self.context['tz'] = DateParser._parse_tz(tz)
def exitTwentyFourHourTimeExpr(
self, ctx: dateparse_utilsParser.TwentyFourHourTimeExprContext
) -> None:
try:
hour = ctx.hour().getText()
while not hour[-1].isdigit():
hour = hour[:-1]
hour = DateParser._get_int(hour)
except Exception as e:
raise PyUtilsDateParseException(f'Bad hour: {ctx.hour().getText()}') from e
if hour < 0 or hour > 23:
raise PyUtilsDateParseException(f'Bad hour (out of range): {hour}')
self.context['hour'] = hour
try:
minute = DateParser._get_int(ctx.minute().getText())
except Exception:
minute = 0
if minute < 0 or minute > 59:
raise PyUtilsDateParseException(
f'Bad minute (out of range): {ctx.getText()}'
)
self.context['minute'] = minute
try:
seconds = DateParser._get_int(ctx.second().getText())
except Exception:
seconds = 0
if seconds < 0 or seconds > 59:
raise PyUtilsDateParseException(
f'Bad second (out of range): {ctx.getText()}'
)
self.context['seconds'] = seconds
try:
micros = DateParser._get_int(ctx.micros().getText())
except Exception:
micros = 0
if micros < 0 or micros >= 1000000:
raise PyUtilsDateParseException(
f'Bad micros (out of range): {ctx.getText()}'
)
self.context['micros'] = micros
if ctx.tzExpr():
tz = ctx.tzExpr().getText()
self.context['tz'] = DateParser._parse_tz(tz)
@bootstrap.initialize
def main() -> None:
parser = DateParser()
for line in sys.stdin:
line = line.strip()
line = re.sub(r"#.*$", "", line)
if re.match(r"^ *$", line) is not None:
continue
try:
dt = parser.parse(line)
except Exception:
logger.exception("Could not parse supposed date expression: %s", line)
print("Unrecognized.")
else:
assert dt is not None
print(dt.strftime('%A %Y/%m/%d %H:%M:%S.%f %Z(%z)'))
sys.exit(0)
if __name__ == "__main__":
main()