Skip to content

Commit

Permalink
added half bound keywords
Browse files Browse the repository at this point in the history
  • Loading branch information
Zeutschler committed Sep 21, 2024
1 parent f1a0ff0 commit ba601fc
Show file tree
Hide file tree
Showing 10 changed files with 523 additions and 263 deletions.
2 changes: 1 addition & 1 deletion datespanlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from datespanlib.date_span_set import DateSpanSet

__author__ = "Thomas Zeutschler"
__version__ = "0.1.7"
__version__ = "0.1.8"
__license__ = "MIT"
VERSION = __version__

Expand Down
372 changes: 262 additions & 110 deletions datespanlib/date_span.py

Large diffs are not rendered by default.

165 changes: 115 additions & 50 deletions datespanlib/parser/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ def evaluate_node(self, node):

elif node_type == 'range':
return self.evaluate_range(node.value['start_tokens'], node.value['end_tokens'])
elif node_type == 'since':
return self.evaluate_since(node.value['tokens'])
# elif node_type == 'since':
# return self.evaluate_since(node.value['tokens'])
elif node_type == 'half_bound':
return self.evaluate_half_bound(node.value['tokens'], node.value['value'])

elif node_type == 'iterative':
return self.evaluate_iterative(node.value['tokens'], node.value['period_tokens'])
else:
Expand Down Expand Up @@ -171,6 +174,57 @@ def evaluate_since(self, tokens):
end_date = self.today
return [(start_date, end_date)]

def evaluate_half_bound(self, tokens, keyword):
"""
Evaluates a half bounded expression, calculating the date range from or to a specified date/time.
since - from date to now
after - from date to date max
before - from date min to date
until - from date min to date
"""
# Parse the date/time expression following 'since', 'after', 'before', or 'until'
if len(tokens) == 0:
raise EvaluationError(f"Date of date span missing after "
f"'since', 'after', 'before', or 'until'.")

parser = Parser(tokens + [Token(TokenType.EOF)])
try:
ast_nodes = parser.parse_statement()
except ParsingError as e:
raise EvaluationError(f"Failed to parse date of date span after 'since', 'after', 'before', or 'until': {e}")
if not ast_nodes:
raise EvaluationError(f"Date of date span missing after "
f"'since', 'after', 'before', or 'until'.")

try:
spans = self.evaluate_node(ast_nodes[0])
except EvaluationError as e:
raise EvaluationError(f"Failed to evaluate date of date span after "
f"'since', 'after', 'before', or 'until': {e}")
if not spans:
raise EvaluationError(f"Failed to evaluate date of date span after "
f"'since', 'after', 'before', or 'until'.")

if keyword == 'since':
start_date = spans[0][0]
end_date = self.today
elif keyword == 'from':
start_date = spans[0][0]
end_date = datetime.max
elif keyword == 'after':
start_date = spans[0][1] + timedelta(microseconds=1)
end_date = datetime.max
elif keyword in ['before', 'until']:
start_date = datetime.min
end_date = spans[0][0] - timedelta(microseconds=1)
else:
raise EvaluationError(
f"Failed to evaluate date or datespan. Expected 'since', "
f"'after', 'before', or 'until' but got '{keyword}'.")
return [(start_date, end_date)]



def evaluate_iterative(self, tokens, period_tokens):
"""
Evaluates an iterative date expression and returns the corresponding date spans.
Expand Down Expand Up @@ -335,49 +389,49 @@ def evaluate_special(self, value):
elif value == 'now':
return DateSpan.now().to_tuple_list()
elif value == 'ltm':
return DateSpan.ltm().to_tuple_list()
return DateSpan().ltm.to_tuple_list()
elif value == 'ytd':
return DateSpan.ytd().to_tuple_list()
return DateSpan().ytd.to_tuple_list()
elif value == 'qtd':
return DateSpan.qtd().to_tuple_list()
return DateSpan().qtd.to_tuple_list()
elif value == 'mtd':
return DateSpan.mtd().to_tuple_list()
return DateSpan().mtd.to_tuple_list()
elif value == 'wtd':
return DateSpan.wtd().to_tuple_list()
return DateSpan().wtd.to_tuple_list()

# catch the following single words as specials
elif value == 'week':
return DateSpan.now().full_week().to_tuple_list()
return DateSpan.now().full_week.to_tuple_list()
elif value == 'month':
return DateSpan.now().full_month().to_tuple_list()
return DateSpan.now().full_month.to_tuple_list()
elif value == 'year':
return DateSpan.now().full_year().to_tuple_list()
return DateSpan.now().full_year.to_tuple_list()
elif value == 'quarter':
return DateSpan.now().full_quarter().to_tuple_list()
return DateSpan.now().full_quarter.to_tuple_list()
elif value == 'hour':
return DateSpan.now().full_hour().to_tuple_list()
return DateSpan.now().full_hour.to_tuple_list()
elif value == 'minute':
return DateSpan.now().full_minute().to_tuple_list()
return DateSpan.now().full_minute.to_tuple_list()
elif value == 'second':
return DateSpan.now().full_second().to_tuple_list()
return DateSpan.now().full_second.to_tuple_list()
elif value == 'millisecond':
return DateSpan.now().full_millisecond().to_tuple_list()
return DateSpan.now().full_millisecond.to_tuple_list()


elif value in ['q1', 'q2', 'q3', 'q4']:
# Specific quarter
year = DateSpan.now().start.year
quarter = int(value[1])
month = 3 * (quarter - 1) + 1
return DateSpan(datetime(year= year, month=month, day=1)).full_quarter().to_tuple_list()
return DateSpan(datetime(year= year, month=month, day=1)).full_quarter.to_tuple_list()
elif value == 'py':
return DateSpan.now().shift(years=-1).full_year().to_tuple_list()
return DateSpan.now().shift(years=-1).full_year.to_tuple_list()
elif value == 'cy':
return DateSpan.now().full_year().to_tuple_list()
return DateSpan.now().full_year.to_tuple_list()
elif value == 'ny':
return DateSpan.now().shift(years=1).full_year().to_tuple_list()
return DateSpan.now().shift(years=1).full_year.to_tuple_list()
elif value == 'ly':
return DateSpan.now().shift(years=-1).full_year().to_tuple_list()
return DateSpan.now().shift(years=-1).full_year.to_tuple_list()
else:
return []

Expand Down Expand Up @@ -448,11 +502,22 @@ def evaluate_days(self, tokens):
days.append(day_full_name)
idx += 1
date_spans = []
lookups = {"monday": DateSpan.monday, "tuesday": DateSpan.tuesday, "wednesday": DateSpan.wednesday,
"thursday": DateSpan.thursday, "friday": DateSpan.friday, "saturday": DateSpan.saturday,
"sunday": DateSpan.sunday}

for day_name in days:
span = lookups[day_name]()
if day_name == 'monday':
span = DateSpan().monday
elif day_name == 'tuesday':
span = DateSpan().tuesday
elif day_name == 'wednesday':
span = DateSpan().wednesday
elif day_name == 'thursday':
span = DateSpan().thursday
elif day_name == 'friday':
span = DateSpan().friday
elif day_name == 'saturday':
span = DateSpan().saturday
elif day_name == 'sunday':
span = DateSpan().sunday
date_spans.append((span.start, span.end))
return date_spans

Expand All @@ -469,7 +534,7 @@ def calculate_rolling(self, number, unit):
elif unit == 'quarter':
return DateSpan.now().shift_start(months=-number * 3).to_tuple_list()
elif unit == 'week':
return DateSpan.now().shift(weeks=-1).full_week().shift_start(weeks=number - 1).to_tuple_list()
return DateSpan.now().shift(weeks=-1).full_week.shift_start(weeks=number - 1).to_tuple_list()
elif unit == 'day':
return DateSpan.now().shift_start(days=-number).to_tuple_list()
elif unit == 'hour':
Expand All @@ -490,23 +555,23 @@ def calculate_previous(self, number, unit):
Note: Previous and last are synonyms.
"""
if unit == 'month': # most used units first
return DateSpan.today().shift(months=-1).full_month().shift_start(months=-(number - 1)).to_tuple_list()
return DateSpan.today().shift(months=-1).full_month.shift_start(months=-(number - 1)).to_tuple_list()
elif unit == 'year':
return DateSpan.today().shift(years=-1).full_year().shift_start(years=-(number - 1)).to_tuple_list()
return DateSpan.today().shift(years=-1).full_year.shift_start(years=-(number - 1)).to_tuple_list()
elif unit == 'quarter':
return DateSpan.today().shift(months=-3).full_quarter().shift_start(months=-(number - 1) * 3).to_tuple_list()
return DateSpan.today().shift(months=-3).full_quarter.shift_start(months=-(number - 1) * 3).to_tuple_list()
elif unit == 'week':
return DateSpan.today().shift(weeks=-1).full_week().shift_start(weeks=-(number - 1)).to_tuple_list()
return DateSpan.today().shift(weeks=-1).full_week.shift_start(weeks=-(number - 1)).to_tuple_list()
elif unit == 'day':
return DateSpan.yesterday().shift_start(days=-(number - 1)).to_tuple_list()
elif unit == 'hour':
return DateSpan.now().shift(hours=-1).full_hour().shift_start(hours=-(number - 1)).to_tuple_list()
return DateSpan.now().shift(hours=-1).full_hour.shift_start(hours=-(number - 1)).to_tuple_list()
elif unit == 'minute':
return DateSpan.now().shift(minutes=-1).full_minute().shift_start(minutes=-(number - 1)).to_tuple_list()
return DateSpan.now().shift(minutes=-1).full_minute.shift_start(minutes=-(number - 1)).to_tuple_list()
elif unit == 'second':
return DateSpan.now().shift(seconds=-1).full_second().shift_start(seconds=-(number - 1)).to_tuple_list()
return DateSpan.now().shift(seconds=-1).full_second.shift_start(seconds=-(number - 1)).to_tuple_list()
elif unit == 'millisecond':
return DateSpan.now().shift(microseconds=-1000).full_millisecond().shift_start(microseconds=-(number - 1) * 1000).to_tuple_list()
return DateSpan.now().shift(microseconds=-1000).full_millisecond.shift_start(microseconds=-(number - 1) * 1000).to_tuple_list()
else:
return []

Expand All @@ -518,21 +583,21 @@ def calculate_future(self, number, unit):
if unit == 'day':
return DateSpan.today().shift(days=1).shift_end(days=(number - 1)).to_tuple_list()
elif unit == 'week':
return DateSpan.today().shift(weeks=1).full_week().shift_end(weeks=(number - 1)).to_tuple_list()
return DateSpan.today().shift(weeks=1).full_week.shift_end(weeks=(number - 1)).to_tuple_list()
elif unit == 'month':
return DateSpan.today().shift(months=1).full_month().shift_end(months=(number - 1)).to_tuple_list()
return DateSpan.today().shift(months=1).full_month.shift_end(months=(number - 1)).to_tuple_list()
elif unit == 'year':
return DateSpan.today().shift(years=1).full_year().shift_end(years=(number - 1)).to_tuple_list()
return DateSpan.today().shift(years=1).full_year.shift_end(years=(number - 1)).to_tuple_list()
elif unit == 'quarter':
return DateSpan.today().shift(months=3).full_quarter().shift_end(months=(number - 1)*3).to_tuple_list()
return DateSpan.today().shift(months=3).full_quarter.shift_end(months=(number - 1)*3).to_tuple_list()
elif unit == 'hour':
return DateSpan.now().shift(hours=1).full_hour().shift_end(hours=number - 1).to_tuple_list()
return DateSpan.now().shift(hours=1).full_hour.shift_end(hours=number - 1).to_tuple_list()
elif unit == 'minute':
return DateSpan.now().shift(minutes=1).full_minute().shift_end(minutes=number - 1).to_tuple_list()
return DateSpan.now().shift(minutes=1).full_minute.shift_end(minutes=number - 1).to_tuple_list()
elif unit == 'second':
return DateSpan.now().shift(seconds=1).full_second().shift_end(seconds=number - 1).to_tuple_list()
return DateSpan.now().shift(seconds=1).full_second.shift_end(seconds=number - 1).to_tuple_list()
elif unit == 'millisecond':
return DateSpan.now().shift(microseconds=1000).full_millisecond().shift_end(microseconds=(number - 1) * 1000).to_tuple_list()
return DateSpan.now().shift(microseconds=1000).full_millisecond.shift_end(microseconds=(number - 1) * 1000).to_tuple_list()
else:
return []

Expand All @@ -542,23 +607,23 @@ def calculate_this(self, unit):
Calculates the date range for the current period specified by the unit (day, week, month, year, quarter).
"""
if unit == 'day':
return DateSpan.now().full_day().to_tuple_list()
return DateSpan.now().full_day.to_tuple_list()
elif unit == 'week':
return DateSpan.now().full_week().to_tuple_list()
return DateSpan.now().full_week.to_tuple_list()
elif unit == 'month':
return DateSpan.now().full_month().to_tuple_list()
return DateSpan.now().full_month.to_tuple_list()
elif unit == 'year':
return DateSpan.now().full_year().to_tuple_list()
return DateSpan.now().full_year.to_tuple_list()
elif unit == 'quarter':
return DateSpan.now().full_quarter().to_tuple_list()
return DateSpan.now().full_quarter.to_tuple_list()
elif unit == 'hour':
return DateSpan.now().full_hour().to_tuple_list()
return DateSpan.now().full_hour.to_tuple_list()
elif unit == 'minute':
return DateSpan.now().full_minute().to_tuple_list()
return DateSpan.now().full_minute.to_tuple_list()
elif unit == 'second':
return DateSpan.now().full_second().to_tuple_list()
return DateSpan.now().full_second.to_tuple_list()
elif unit == 'millisecond':
return DateSpan.now().full_millisecond().to_tuple_list()
return DateSpan.now().full_millisecond.to_tuple_list()
else:
return []

Expand Down
18 changes: 16 additions & 2 deletions datespanlib/parser/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,24 @@ class Lexer:

'next': 'next',

'since': 'since',
'and': 'and',
'of': 'of',
'in': 'in', # Added 'in' to IDENTIFIER_ALIASES
'in': 'in',

'since': 'since',
'until': 'until',
'till': 'until',
'up to': 'upto', # not yet implemented

'before': 'before',
'bef': 'before',
'bfr': 'before',
'ante': 'before',

'after': 'after',
'aft': 'after',
'aftr': 'after',
'post': 'after',

'from': 'from',
'frm': 'from',
Expand Down
29 changes: 26 additions & 3 deletions datespanlib/parser/parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from anyio.lowlevel import current_token

from datespanlib.parser.errors import ParsingError
from datespanlib.parser.lexer import Token, TokenType, Lexer

Expand Down Expand Up @@ -100,10 +102,12 @@ def date_span(self):
if self.current_token.type == TokenType.IDENTIFIER:
if self.current_token.value == 'every':
return self.iterative_date_span()
elif self.current_token.value in ['last', 'next', 'past', 'this', 'previous', 'rolling']:
elif self.current_token.value in ['last', 'next', 'past', 'previous', 'rolling', 'this']:
return self.relative_date_span()
elif self.current_token.value == 'since':
return self.since_date_span()
# elif self.current_token.value == 'since':
# return self.since_date_span()
elif self.current_token.value in ['after', 'before', 'since', 'until']:
return self.half_bound_date_span()
elif self.current_token.value in ['between', 'from']:
return self.date_range()
elif self.current_token.value in Lexer.MONTH_ALIASES.values():
Expand Down Expand Up @@ -215,6 +219,7 @@ def date_range(self):
"""
Parses a date range expression, such as 'from ... to ...' or 'between ... and ...'.
"""
token = self.current_token
self.eat(TokenType.IDENTIFIER) # Consume 'from' or 'between'
# Parse the start date expression
start_tokens = []
Expand All @@ -226,6 +231,9 @@ def date_range(self):
if self.current_token.type == TokenType.IDENTIFIER and self.current_token.value in ['and', 'to']:
self.eat(TokenType.IDENTIFIER)
else:
if token.value == 'from':
# special case, 'from' without 'to' or 'and'
return DateSpanNode({'type': 'half_bound', 'tokens': start_tokens, 'value': token.value})
raise ParsingError(
f"Expected 'and' or 'to', got '{self.current_token.value!r}'",
self.current_token.line,
Expand Down Expand Up @@ -255,6 +263,21 @@ def since_date_span(self):
self.eat(self.current_token.type)
return DateSpanNode({'type': 'since', 'tokens': tokens})

def half_bound_date_span(self):
"""
Parses date time spans with a one side bound like 'since', 'before', 'after' date expression,
e.g. as in 'since August 2024', 'after Friday' or 'before 2024'.
"""
token = self.current_token
self.eat(TokenType.IDENTIFIER) # Consume half bound keyword
tokens = []
while self.current_token.type != TokenType.EOF and \
not (self.current_token.type == TokenType.PUNCTUATION or
self.current_token.type == TokenType.SEMICOLON):
tokens.append(self.current_token)
self.eat(self.current_token.type)
return DateSpanNode({'type': 'half_bound', 'tokens': tokens, 'value': token.value})

def relative_date_span(self):
"""
Parses a relative date span, such as 'last week' or 'next 3 months'.
Expand Down
Loading

0 comments on commit ba601fc

Please sign in to comment.