summaryrefslogtreecommitdiff
path: root/examples/delta_time.py
diff options
context:
space:
mode:
Diffstat (limited to 'examples/delta_time.py')
-rw-r--r--examples/delta_time.py318
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)