diff options
Diffstat (limited to 'examples/delta_time.py')
-rw-r--r-- | examples/delta_time.py | 318 |
1 files changed, 318 insertions, 0 deletions
diff --git a/examples/delta_time.py b/examples/delta_time.py new file mode 100644 index 0000000..5c6ceb7 --- /dev/null +++ b/examples/delta_time.py @@ -0,0 +1,318 @@ +# 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.Or(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").setParseAction(pp.replaceWith(1)) +the_qty = CK("the").setParseAction(pp.replaceWith(1)) +qty = 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[0] + 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[0] * {'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 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'): + del t[k] + +time_and_day.addParseAction(save_original_string, compute_timestamp, remove_temp_keys) + + +time_expression = time_and_day + + +if __name__ == "__main__": + # 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 + 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 + 20 seconds before noon + ten seconds before noon tomorrow + noon + midnight + noon tomorrow + 6am tomorrow + 0800 yesterday + 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 + """ + + 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), + 'a week from now': timedelta(days=7), + } + def verify_offset(instring, parsed): + if instring in expected: + if parsed.time_offset == expected[instring]: + parsed['verify_offset'] = 'PASS' + else: + parsed['verify_offset'] = 'FAIL' + + print("(relative to %s)" % datetime.now()) + time_expression.runTests(tests, postParse=verify_offset) |