# deltaTime.py # # Parser to convert a conversational time reference such as "in a minute" or # "noon tomorrow" and convert it to a Python datetime. The returned # ParseResults object contains # - original - the original time expression string # - computed_dt - the Python datetime representing the computed time # - relative_to - the reference "now" time # - time_offset - the difference between the reference time and the computed time # # BNF: # time_and_day ::= time_reference [day_reference] | day_reference 'at' absolute_time_of_day # day_reference ::= absolute_day_reference | relative_day_reference # absolute_day_reference ::= 'today' | 'tomorrow' | 'yesterday' | ('next' | 'last') weekday_name # relative_day_reference ::= 'in' qty day_units # | qty day_units 'ago' # | 'qty day_units ('from' | 'before' | 'after') absolute_day_reference # day_units ::= 'days' | 'weeks' # # time_reference ::= absolute_time_of_day | relative_time_reference # relative_time_reference ::= qty time_units ('from' | 'before' | 'after') absolute_time_of_day # | qty time_units 'ago' # | 'in' qty time_units # time_units ::= 'hours' | 'minutes' | 'seconds' # absolute_time_of_day ::= 'noon' | 'midnight' | 'now' | absolute_time # absolute_time ::= 24hour_time | hour ("o'clock" | ':' minute) ('AM'|'PM') # # qty ::= integer | integer_words | 'a couple of' | 'a' | 'the' # # Copyright 2010, 2019 by Paul McGuire # from datetime import datetime, time, timedelta import pyparsing as pp import calendar __all__ = ["time_expression"] # basic grammar definitions def make_integer_word_expr(int_name, int_value): return pp.CaselessKeyword(int_name).addParseAction(pp.replaceWith(int_value)) integer_word = pp.MatchFirst(make_integer_word_expr(int_str, int_value) for int_value, int_str in enumerate("one two three four five six seven eight nine ten" " eleven twelve thirteen fourteen fifteen sixteen" " seventeen eighteen nineteen twenty".split(), start=1)) integer = pp.pyparsing_common.integer | integer_word CK = pp.CaselessKeyword CL = pp.CaselessLiteral today, tomorrow, yesterday, noon, midnight, now = map(CK, "today tomorrow yesterday noon midnight now".split()) def plural(s): return CK(s) | CK(s + 's').addParseAction(pp.replaceWith(s)) week, day, hour, minute, second = map(plural, "week day hour minute second".split()) am = CL("am") pm = CL("pm") COLON = pp.Suppress(':') in_ = CK("in").setParseAction(pp.replaceWith(1)) from_ = CK("from").setParseAction(pp.replaceWith(1)) before = CK("before").setParseAction(pp.replaceWith(-1)) after = CK("after").setParseAction(pp.replaceWith(1)) ago = CK("ago").setParseAction(pp.replaceWith(-1)) next_ = CK("next").setParseAction(pp.replaceWith(1)) last_ = CK("last").setParseAction(pp.replaceWith(-1)) at_ = CK("at") on_ = CK("on") couple = (pp.Optional(CK("a")) + CK("couple") + pp.Optional(CK("of"))).setParseAction(pp.replaceWith(2)) a_qty = (CK("a") | CK("an")).setParseAction(pp.replaceWith(1)) the_qty = CK("the").setParseAction(pp.replaceWith(1)) qty = pp.ungroup(integer | couple | a_qty | the_qty) time_ref_present = pp.Empty().addParseAction(pp.replaceWith(True))('time_ref_present') def fill_24hr_time_fields(t): t['HH'] = t[0] t['MM'] = t[1] t['SS'] = 0 t['ampm'] = ('am','pm')[t.HH >= 12] def fill_default_time_fields(t): for fld in 'HH MM SS'.split(): if fld not in t: t[fld] = 0 weekday_name_list = list(calendar.day_name) weekday_name = pp.oneOf(weekday_name_list) _24hour_time = pp.Word(pp.nums, exact=4).addParseAction(lambda t: [int(t[0][:2]),int(t[0][2:])], fill_24hr_time_fields) _24hour_time.setName("0000 time") ampm = am | pm timespec = (integer("HH") + pp.Optional(CK("o'clock") | COLON + integer("MM") + pp.Optional(COLON + integer("SS")) ) + (am | pm)("ampm") ).addParseAction(fill_default_time_fields) absolute_time = _24hour_time | timespec absolute_time_of_day = noon | midnight | now | absolute_time def add_computed_time(t): if t[0] in 'now noon midnight'.split(): t['computed_time'] = {'now': datetime.now().time().replace(microsecond=0), 'noon': time(hour=12), 'midnight': time()}[t[0]] else: t['HH'] = {'am': int(t['HH']) % 12, 'pm': int(t['HH']) % 12 + 12}[t.ampm] t['computed_time'] = time(hour=t.HH, minute=t.MM, second=t.SS) absolute_time_of_day.addParseAction(add_computed_time) # relative_time_reference ::= qty time_units ('from' | 'before' | 'after') absolute_time_of_day # | qty time_units 'ago' # | 'in' qty time_units time_units = hour | minute | second relative_time_reference = (qty('qty') + time_units('units') + ago('dir') | qty('qty') + time_units('units') + (from_ | before | after)('dir') + pp.Group(absolute_time_of_day)('ref_time') | in_('dir') + qty('qty') + time_units('units') ) def compute_relative_time(t): if 'ref_time' not in t: t['ref_time'] = datetime.now().time().replace(microsecond=0) else: t['ref_time'] = t.ref_time.computed_time delta_seconds = {'hour': 3600, 'minute': 60, 'second': 1}[t.units] * t.qty t['time_delta'] = timedelta(seconds=t.dir * delta_seconds) relative_time_reference.addParseAction(compute_relative_time) time_reference = absolute_time_of_day | relative_time_reference def add_default_time_ref_fields(t): if 'time_delta' not in t: t['time_delta'] = timedelta() time_reference.addParseAction(add_default_time_ref_fields) # absolute_day_reference ::= 'today' | 'tomorrow' | 'yesterday' | ('next' | 'last') weekday_name # day_units ::= 'days' | 'weeks' day_units = day | week weekday_reference = pp.Optional(next_ | last_, 1)('dir') + weekday_name('day_name') def convert_abs_day_reference_to_date(t): now = datetime.now().replace(microsecond=0) # handle day reference by weekday name if 'day_name' in t: todaynum = now.weekday() daynames = [n.lower() for n in weekday_name_list] nameddaynum = daynames.index(t.day_name.lower()) # compute difference in days - if current weekday name is referenced, then # computed 0 offset is changed to 7 if t.dir > 0: daydiff = (nameddaynum + 7 - todaynum) % 7 or 7 else: daydiff = -((todaynum + 7 - nameddaynum) % 7 or 7) t["abs_date"] = datetime(now.year, now.month, now.day) + timedelta(daydiff) else: name = t[0] t["abs_date"] = { "now" : now, "today" : datetime(now.year, now.month, now.day), "yesterday" : datetime(now.year, now.month, now.day) + timedelta(days=-1), "tomorrow" : datetime(now.year, now.month, now.day) + timedelta(days=+1), }[name] absolute_day_reference = today | tomorrow | yesterday | now + time_ref_present | weekday_reference absolute_day_reference.addParseAction(convert_abs_day_reference_to_date) # relative_day_reference ::= 'in' qty day_units # | qty day_units 'ago' # | 'qty day_units ('from' | 'before' | 'after') absolute_day_reference relative_day_reference = (in_('dir') + qty('qty') + day_units('units') | qty('qty') + day_units('units') + ago('dir') | qty('qty') + day_units('units') + (from_ | before | after)('dir') + absolute_day_reference('ref_day') ) def compute_relative_date(t): now = datetime.now().replace(microsecond=0) if 'ref_day' in t: t['computed_date'] = t.ref_day else: t['computed_date'] = now.date() day_diff = t.dir * t.qty * {'week': 7, 'day': 1}[t.units] t['date_delta'] = timedelta(days=day_diff) relative_day_reference.addParseAction(compute_relative_date) # combine expressions for absolute and relative day references day_reference = relative_day_reference | absolute_day_reference def add_default_date_fields(t): if 'date_delta' not in t: t['date_delta'] = timedelta() day_reference.addParseAction(add_default_date_fields) # combine date and time expressions into single overall parser time_and_day = (time_reference + time_ref_present + pp.Optional(pp.Optional(on_) + day_reference) | day_reference + pp.Optional(at_ + absolute_time_of_day + time_ref_present)) # parse actions for total time_and_day expression def save_original_string(s, l, t): # save original input string and reference time t['original'] = ' '.join(s.strip().split()) t['relative_to'] = datetime.now().replace(microsecond=0) def compute_timestamp(t): # accumulate values from parsed time and day subexpressions - fill in defaults for omitted parts now = datetime.now().replace(microsecond=0) if 'computed_time' not in t: t['computed_time'] = t.ref_time or now.time() if 'abs_date' not in t: t['abs_date'] = now # roll up all fields and apply any time or day deltas t['computed_dt'] = ( t.abs_date.replace(hour=t.computed_time.hour, minute=t.computed_time.minute, second=t.computed_time.second) + (t.time_delta or timedelta(0)) + (t.date_delta or timedelta(0)) ) # if time just given in terms of day expressions, zero out time fields if not t.time_ref_present: t['computed_dt'] = t.computed_dt.replace(hour=0, minute=0, second=0) # add results name compatible with previous version t['calculatedTime'] = t.computed_dt # add time_offset fields t['time_offset'] = t.computed_dt - t.relative_to def remove_temp_keys(t): # strip out keys that are just used internally all_keys = list(t.keys()) for k in all_keys: if k not in ('computed_dt', 'original', 'relative_to', 'time_offset', 'calculatedTime'): del t[k] time_and_day.addParseAction(save_original_string, compute_timestamp, remove_temp_keys) time_expression = time_and_day if __name__ == "__main__": current_time = datetime.now() # test grammar tests = """\ today tomorrow yesterday the day before yesterday the day after tomorrow 2 weeks after today in a couple of days a couple of days from now a couple of days from today in a day 3 days ago 3 days from now a day ago an hour ago in 2 weeks in 3 days at 5pm now 10 minutes ago 10 minutes from now in 10 minutes in a minute in a couple of minutes 20 seconds ago in 30 seconds in an hour in a couple hours in a couple days 20 seconds before noon ten seconds before noon tomorrow noon midnight noon tomorrow 6am tomorrow 0800 yesterday 1700 tomorrow 12:15 AM today 3pm 2 days from today a week from today a week from now three weeks ago noon next Sunday noon Sunday noon last Sunday 2pm next Sunday next Sunday at 2pm last Sunday at 2pm """ time_of_day = timedelta(hours=current_time.hour, minutes=current_time.minute, seconds=current_time.second) expected = { 'now' : timedelta(0), '10 minutes ago': timedelta(minutes=-10), '10 minutes from now': timedelta(minutes=10), 'in 10 minutes': timedelta(minutes=10), 'in a minute': timedelta(minutes=1), 'in a couple of minutes': timedelta(minutes=2), '20 seconds ago': timedelta(seconds=-20), 'in 30 seconds': timedelta(seconds=30), 'in an hour': timedelta(hours=1), 'in a couple hours': timedelta(hours=2), 'a week from now': timedelta(days=7), '3 days from now': timedelta(days=3), 'a couple of days from now': timedelta(days=2), 'an hour ago': timedelta(hours=-1), 'in a couple days': timedelta(days=2) - time_of_day, 'a week from today': timedelta(days=7) - time_of_day, 'three weeks ago': timedelta(days=-21) - time_of_day, 'a day ago': timedelta(days=-1) - time_of_day, 'in a couple of days': timedelta(days=2) - time_of_day, 'a couple of days from today': timedelta(days=2) - time_of_day, '2 weeks after today': timedelta(days=14) - time_of_day, 'in 2 weeks': timedelta(days=14) - time_of_day, 'the day after tomorrow': timedelta(days=2) - time_of_day, 'tomorrow': timedelta(days=1) - time_of_day, 'the day before yesterday': timedelta(days=-2) - time_of_day, 'yesterday': timedelta(days=-1) - time_of_day, 'today': -time_of_day, 'midnight': -time_of_day, 'in a day': timedelta(days=1) - time_of_day, '3 days ago': timedelta(days=-3) - time_of_day, 'noon tomorrow': timedelta(days=1) - time_of_day + timedelta(hours=12), '6am tomorrow': timedelta(days=1) - time_of_day + timedelta(hours=6), '0800 yesterday': timedelta(days=-1) - time_of_day + timedelta(hours=8), '1700 tomorrow': timedelta(days=1) - time_of_day + timedelta(hours=17), '12:15 AM today': -time_of_day + timedelta(minutes=15), '3pm 2 days from today': timedelta(days=2) - time_of_day + timedelta(hours=15), 'ten seconds before noon tomorrow': timedelta(days=1) - time_of_day + timedelta(hours=12) + timedelta(seconds=-10), '20 seconds before noon': -time_of_day + timedelta(hours=12) + timedelta(seconds=-20), 'in 3 days at 5pm': timedelta(days=3) - time_of_day + timedelta(hours=17), } def verify_offset(instring, parsed): time_epsilon = timedelta(seconds=1) if instring in expected: # allow up to a second time discrepancy due to test processing time if (parsed.time_offset - expected[instring]) <= time_epsilon: parsed['verify_offset'] = 'PASS' else: parsed['verify_offset'] = 'FAIL' print("(relative to %s)" % datetime.now()) time_expression.runTests(tests, postParse=verify_offset)