From 0c59a5773188f88b2ddbfdb6cfef1f2f0f8a49b7 Mon Sep 17 00:00:00 2001 From: ptmcg Date: Sat, 30 Mar 2024 17:50:51 -0500 Subject: [PATCH] Clean up delta_time.py example --- CHANGES | 7 +- examples/delta_time.py | 283 +++++++++++++++++++++-------------------- 2 files changed, 151 insertions(+), 139 deletions(-) diff --git a/CHANGES b/CHANGES index 77b9ea6d..887dc750 100644 --- a/CHANGES +++ b/CHANGES @@ -15,13 +15,16 @@ Version 3.2.0 will also discontinue support for Python versions 3.6 and 3.7. Version 3.1.3 - in development ------------------------------ -- Fixed issue where PEP8 compatibility names for `ParserElement` staticmethods were - not themselves defined as staticmethods. When called using a `ParserElement` instance, +- Fixed issue where PEP8 compatibility names for `ParserElement` static methods were + not themselves defined as `staticmethods`. When called using a `ParserElement` instance, this resulted in a `TypeError` exception. Reported by eylenburg (#548). - Some type annotations added for parse action related methods, thanks August Karlstedt (#551). +- delta_time.py example cleaned up to use latest PEP8 names and add minor + enhancements. + - Added early testing support for Python 3.13 with JIT enabled. diff --git a/examples/delta_time.py b/examples/delta_time.py index 9b502901..d50d7b41 100644 --- a/examples/delta_time.py +++ b/examples/delta_time.py @@ -1,4 +1,4 @@ -# deltaTime.py +# delta_time.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 @@ -30,24 +30,29 @@ # Copyright 2010, 2019 by Paul McGuire # -from datetime import datetime, time, timedelta -import pyparsing as pp import calendar +from datetime import datetime, time as datetime_time, timedelta +from typing import Optional + +import pyparsing as pp __all__ = ["time_expression"] # basic grammar definitions -def make_integer_word_expr(int_name, int_value): - return pp.CaselessKeyword(int_name).add_parse_action(pp.replaceWith(int_value)) +def _make_integer_word_expr(int_name: str, int_value: int) -> pp.CaselessKeyword: + return pp.CaselessKeyword( + int_name, ident_chars=pp.srange("[A-Za-z-]") + ).add_parse_action(pp.replace_with(int_value)) integer_word = pp.MatchFirst( - make_integer_word_expr(int_str, int_value) + _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(), + " seventeen eighteen nineteen twenty twenty-one" + " twenty-two twenty-three twenty-four".split(), start=1, ) ).set_name("integer_word") @@ -62,11 +67,13 @@ def make_integer_word_expr(int_name, int_value): ) -def plural(s): - return CK(s) | CK(s + "s").add_parse_action(pp.replaceWith(s)) +def _singular_or_plural(s: str) -> pp.ParserElement: + return CK(s) | CK(s + "s").add_parse_action(pp.replace_with(s)) -week, day, hour, minute, second = map(plural, "week day hour minute second".split()) +week, day, hour, minute, second = map( + _singular_or_plural, "week day hour minute second".split() +) time_units = hour | minute | second any_time_units = (week | day | time_units).set_name("any_time_units") @@ -74,62 +81,68 @@ def plural(s): pm = CL("pm") COLON = pp.Suppress(":") -in_ = CK("in").set_parse_action(pp.replaceWith(1)) -from_ = CK("from").set_parse_action(pp.replaceWith(1)) -before = CK("before").set_parse_action(pp.replaceWith(-1)) -after = CK("after").set_parse_action(pp.replaceWith(1)) -ago = CK("ago").set_parse_action(pp.replaceWith(-1)) -next_ = CK("next").set_parse_action(pp.replaceWith(1)) -last_ = CK("last").set_parse_action(pp.replaceWith(-1)) +in_ = CK("in").set_parse_action(pp.replace_with(1)) +from_ = CK("from").set_parse_action(pp.replace_with(1)) +before = CK("before").set_parse_action(pp.replace_with(-1)) +after = CK("after").set_parse_action(pp.replace_with(1)) +ago = CK("ago").set_parse_action(pp.replace_with(-1)) +next_ = CK("next").set_parse_action(pp.replace_with(1)) +last_ = CK("last").set_parse_action(pp.replace_with(-1)) at_ = CK("at") on_ = CK("on") +a_ = CK("a") +an_ = CK("an") +of_ = CK("of") +the_ = CK("the") +adverb_ = pp.MatchFirst(CK.using_each("just only exactly".split())).suppress() couple = ( (pp.Opt(CK("a")) + CK("couple") + pp.Opt(CK("of"))) - .set_parse_action(pp.replaceWith(2)) + .set_parse_action(pp.replace_with(2)) .set_name("couple") ) -a_qty = (CK("a") | CK("an")).set_parse_action(pp.replaceWith(1)) -the_qty = CK("the").set_parse_action(pp.replaceWith(1)) +a_qty = (a_ | an_).set_parse_action(pp.replace_with(1)) +the_qty = the_.set_parse_action(pp.replace_with(1)) qty = pp.ungroup( - (integer | couple | a_qty | the_qty).set_name("qty_expression") + pp.Opt(adverb_) + (integer | couple | a_qty | the_qty).set_name("qty_expression") ).set_name("qty") time_ref_present = pp.Empty().add_parse_action(pp.replace_with(True))( "time_ref_present" ) -def fill_24hr_time_fields(t): +def _fill_24hr_time_fields(t: pp.ParseResults) -> None: t["HH"] = t[0] t["MM"] = t[1] t["SS"] = 0 - t["ampm"] = ("am", "pm")[t.HH >= 12] + t["ampm"] = "am" if t.HH < 12 else "pm" -def fill_default_time_fields(t): +def _fill_default_time_fields(t: pp.ParseResults) -> None: for fld in "HH MM SS".split(): if fld not in t: t[fld] = 0 # get weekday names from the calendar module -weekday_name_list = list(calendar.day_name) -weekday_name = pp.one_of(weekday_name_list).set_name("weekday_name") +weekday_names = list(calendar.day_name) +weekday_name = pp.MatchFirst(CK.using_each(weekday_names)).set_name("weekday_name") # expressions for military 2400 time -_24hour_time = ~(pp.Word(pp.nums) + any_time_units).set_name("numbered_time_units") + pp.Word( - pp.nums, exact=4, as_keyword=True -).set_name("HHMM").add_parse_action( - lambda t: [int(t[0][:2]), int(t[0][2:])], fill_24hr_time_fields +_24hour_time = ~(pp.Word(pp.nums) + any_time_units).set_name( + "numbered_time_units" +) + pp.Word(pp.nums, exact=4, as_keyword=True).set_name("HHMM").add_parse_action( + lambda t: [int(t[0][:2]), int(t[0][2:])], _fill_24hr_time_fields ) _24hour_time.set_name("0000 time") ampm = am | pm +o_clock = CK("o'clock", ident_chars=pp.srange("[A-Za-z']")) timespec = ( integer("HH") - + pp.Opt(CK("o'clock") | COLON + integer("MM") + pp.Opt(COLON + integer("SS"))) + + pp.Opt(o_clock | COLON + integer("MM") + pp.Opt(COLON + integer("SS"))) + (am | pm)("ampm") -).add_parse_action(fill_default_time_fields) +).add_parse_action(_fill_default_time_fields) absolute_time = _24hour_time | timespec absolute_time.set_name("absolute time") @@ -137,19 +150,20 @@ def fill_default_time_fields(t): absolute_time_of_day.set_name("time of day") -def add_computed_time(t): - if t[0] in "now noon midnight".split(): +def _add_computed_time(t: pp.ParseResults) -> None: + initial_word = t[0] + if initial_word in "now noon midnight".split(): t["computed_time"] = { "now": datetime.now().time().replace(microsecond=0), - "noon": time(hour=12), - "midnight": time(), - }[t[0]] + "noon": datetime_time(hour=12), + "midnight": datetime_time(hour=0), + }[initial_word] 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) + t["computed_time"] = datetime_time(hour=t.HH, minute=t.MM, second=t.SS) -absolute_time_of_day.add_parse_action(add_computed_time) +absolute_time_of_day.add_parse_action(_add_computed_time) # relative_time_reference ::= qty time_units ('ago' | ('from' | 'before' | 'after') absolute_time_of_day) @@ -169,7 +183,7 @@ def add_computed_time(t): ).set_name("relative time") -def compute_relative_time(t): +def _compute_relative_time(t: pp.ParseResults) -> None: if "ref_time" not in t: t["ref_time"] = datetime.now().time().replace(microsecond=0) else: @@ -178,18 +192,18 @@ def compute_relative_time(t): t["time_delta"] = timedelta(seconds=t.dir * delta_seconds) -relative_time_reference.add_parse_action(compute_relative_time) +relative_time_reference.add_parse_action(_compute_relative_time) time_reference = absolute_time_of_day | relative_time_reference time_reference.set_name("time reference") -def add_default_time_ref_fields(t): +def _add_default_time_ref_fields(t: pp.ParseResults) -> None: if "time_delta" not in t: t["time_delta"] = timedelta() -time_reference.add_parse_action(add_default_time_ref_fields) +time_reference.add_parse_action(_add_default_time_ref_fields) # absolute_day_reference ::= 'today' | 'tomorrow' | 'yesterday' | ('next' | 'last') weekday_name # day_units ::= 'days' | 'weeks' @@ -198,35 +212,39 @@ def add_default_time_ref_fields(t): weekday_reference = pp.Opt(next_ | last_, 1)("dir") + weekday_name("day_name") -def convert_abs_day_reference_to_date(t): - now = datetime.now().replace(microsecond=0) +def _convert_abs_day_reference_to_date(t: pp.ParseResults) -> None: + now_ref = 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()) + today_num = now_ref.weekday() + day_names = [n.lower() for n in weekday_names] + named_day_num = day_names.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 + day_diff = (named_day_num + 7 - today_num) % 7 or 7 else: - daydiff = -((todaynum + 7 - nameddaynum) % 7 or 7) - t["abs_date"] = datetime(now.year, now.month, now.day) + timedelta(daydiff) + day_diff = -((today_num + 7 - named_day_num) % 7 or 7) + t["abs_date"] = datetime(now_ref.year, now_ref.month, now_ref.day) + timedelta( + day_diff + ) 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), + "now": now_ref, + "today": datetime(now_ref.year, now_ref.month, now_ref.day), + "yesterday": datetime(now_ref.year, now_ref.month, now_ref.day) + + timedelta(days=-1), + "tomorrow": datetime(now_ref.year, now_ref.month, now_ref.day) + + timedelta(days=+1), }[name] absolute_day_reference = ( - today | tomorrow | yesterday | now + time_ref_present | weekday_reference + today | tomorrow | yesterday | (now + time_ref_present) | weekday_reference ) -absolute_day_reference.add_parse_action(convert_abs_day_reference_to_date) +absolute_day_reference.add_parse_action(_convert_abs_day_reference_to_date) absolute_day_reference.set_name("absolute day") # relative_day_reference ::= 'in' qty day_units @@ -241,7 +259,7 @@ def convert_abs_day_reference_to_date(t): relative_day_reference.set_name("relative day") -def compute_relative_date(t): +def _compute_relative_date(t: pp.ParseResults) -> None: now = datetime.now().replace(microsecond=0) if "ref_day" in t: t["computed_date"] = t.ref_day @@ -251,19 +269,19 @@ def compute_relative_date(t): t["date_delta"] = timedelta(days=day_diff) -relative_day_reference.add_parse_action(compute_relative_date) +relative_day_reference.add_parse_action(_compute_relative_date) # combine expressions for absolute and relative day references day_reference = relative_day_reference | absolute_day_reference day_reference.set_name("day reference") -def add_default_date_fields(t): +def _add_default_date_fields(t: pp.ParseResults) -> None: if "date_delta" not in t: t["date_delta"] = timedelta() -day_reference.add_parse_action(add_default_date_fields) +day_reference.add_parse_action(_add_default_date_fields) # combine date and time expressions into single overall parser time_and_day = time_reference + time_ref_present + pp.Opt( @@ -271,14 +289,15 @@ def add_default_date_fields(t): ) | day_reference + pp.Opt(at_ + absolute_time_of_day + time_ref_present) time_and_day.set_name("time and day") + # parse actions for total time_and_day expression -def save_original_string(s, l, t): +def _save_original_string(s: str, _: int, t: pp.ParseResults) -> None: # 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): +def _compute_timestamp(t: pp.ParseResults) -> None: # 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: @@ -308,7 +327,7 @@ def compute_timestamp(t): t["time_offset"] = t.computed_dt - t.relative_to -def remove_temp_keys(t): +def _remove_temp_keys(t: pp.ParseResults) -> None: # strip out keys that are just used internally all_keys = list(t.keys()) for k in all_keys: @@ -322,76 +341,51 @@ def remove_temp_keys(t): del t[k] -time_and_day.add_parse_action(save_original_string, compute_timestamp, remove_temp_keys) +time_and_day.add_parse_action( + _save_original_string, _compute_timestamp, _remove_temp_keys +) time_expression = time_and_day +_GENERATE_DIAGRAM = False +if _GENERATE_DIAGRAM: + pp.autoname_elements() + time_expression.create_diagram("delta_time.html") + # fmt: off def 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 - 10 seconds ago - 100 seconds ago - 1000 seconds ago - 10000 seconds ago - """ - time_of_day = timedelta( hours=current_time.hour, minutes=current_time.minute, seconds=current_time.second, ) - expected = { + + def offset_weekday(day_name: str, offset_dir: int) -> timedelta: + if offset_dir == 1: + offset = 0 + else: + offset = -7 + + day_num = {d: i for i, d in enumerate("Monday Tuesday Wednesday Thursday Friday Saturday Sunday".split())} + return timedelta(days=(0 - current_time.weekday() + day_num[day_name] + offset) % 7) + + def next_weekday_by_name(day_name: str) -> timedelta: + return offset_weekday(day_name, 1) + + def last_weekday_by_name(day_name: str) -> timedelta: + return offset_weekday(day_name, -1) + + # test grammar + tests = { "now": timedelta(0), + "midnight": -time_of_day, + "noon": timedelta(hours=12 - current_time.hour), + "today": -time_of_day, + "tomorrow": timedelta(days=1) - time_of_day, + "yesterday": timedelta(days=-1) - time_of_day, "10 seconds ago": timedelta(seconds=-10), "100 seconds ago": timedelta(seconds=-100), "1000 seconds ago": timedelta(seconds=-1000), @@ -418,12 +412,8 @@ def main(): "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, "8am the day after tomorrow": timedelta(days=+2) - time_of_day + timedelta(hours=8), - "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), @@ -432,26 +422,45 @@ def main(): "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), + "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), + "noon next Sunday": timedelta(hours=12 - current_time.hour) + next_weekday_by_name("Sunday"), + "2pm next Sunday": timedelta(hours=14 - current_time.hour) + next_weekday_by_name("Sunday"), + "2pm Sunday": timedelta(hours=14 - current_time.hour) + next_weekday_by_name("Sunday"), + "next Sunday at 2pm": timedelta(hours=14 - current_time.hour) + next_weekday_by_name("Sunday"), + "noon Sunday": timedelta(hours=12 - current_time.hour) + next_weekday_by_name("Sunday"), + "noon last Sunday": timedelta(hours=12 - current_time.hour) + last_weekday_by_name("Sunday"), + "last Sunday at 2am": timedelta(hours=2 - current_time.hour) + last_weekday_by_name("Sunday"), + "20 hours from now": timedelta(hours=20), + "twenty hours from now": timedelta(hours=20), + "twenty-four hours from now": timedelta(days=1), + "Twenty-four hours from now": timedelta(days=1), + "just twenty-four hours from now": timedelta(days=1), + "in just 10 seconds": timedelta(seconds=10), + "in just a couple of hours": timedelta(hours=2), + "in exactly 1 hour": timedelta(hours=1), + "only one hour from now": timedelta(hours=1), + "only a couple of days ago": timedelta(days=-2), } # fmt: on - def verify_offset(instring, parsed): + def verify_offset(test_time_str: str, parsed: pp.ParseResults) -> Optional[str]: + # allow up to a 1-second time discrepancy due to test processing time 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" + + if (parsed.time_offset - tests[test_time_str]) <= time_epsilon: + parsed["verify_offset"] = "PASS" + else: + parsed["verify_offset"] = "FAIL" print(f"(relative to {datetime.now()})") - success, report = time_expression.runTests(tests, postParse=verify_offset) + success, report = time_expression.run_tests(list(tests), post_parse=verify_offset) assert success fails = [] @@ -461,7 +470,7 @@ def verify_offset(instring, parsed): if fails: print("\nFAILED") - print("\n".join("- " + test for test, rpt in fails)) + print("\n".join(f"- {test}" for test, rpt in fails)) assert not fails