From 674bf3f94a259b4efcbb5a66c86687042df06def Mon Sep 17 00:00:00 2001 From: John Wassilak Date: Sun, 6 Sep 2026 20:49:23 -0500 Subject: [PATCH 1/2] Fix crash on unparseable datetime range in `khal new -i` Fixes #789 --- AUTHORS.txt | 1 + CHANGELOG.rst | 7 +++++++ khal/controllers.py | 12 +++++++++--- tests/cli_test.py | 48 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/AUTHORS.txt b/AUTHORS.txt index 5c8a9faa9..80ac4ada6 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -59,3 +59,4 @@ Pi R Alnoman Kamil - noman [at] kamil [dot] gr - https://kamil.gr Leonardo Taccari - iamleot [at] gmail [dot] com Jorenar - dev [at] jorenar [dot] com - https://jorenar.com +John Wassilak - john [at] wassilak [dot] com diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b126567ed..1ed1c2825 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,13 @@ Package maintainers and users who have to manually update their installation may want to subscribe to `GitHub's tag feed `_. +0.14.2 +====== +unreleased +* FIX ``khal new -i`` crashed and discarded the whole event (summary, etc.) + when given an unparseable datetime range, it now reports the problem and + asks again + 0.14.1 ====== 2026-08-21 diff --git a/khal/controllers.py b/khal/controllers.py index 6148f29ec..0a22553d9 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -378,9 +378,15 @@ def new_interactive( end_string = info["dtend"].strftime(conf["locale"]["datetimeformat"]) range_string = start_string + " " + end_string daterange = prompt("datetime range", default=range_string) - start, end, allday = parse_datetime.guessrangefstr( - daterange, conf["locale"], adjust_reasonably=True - ) + try: + start, end, allday = parse_datetime.guessrangefstr( + daterange, conf["locale"], adjust_reasonably=True + ) + except DateTimeParseError as error: + # Let the user retry just the datetime range instead of losing + # the whole event. + echo(error) + continue info["dtstart"] = start info["dtend"] = end info["allday"] = allday diff --git a/tests/cli_test.py b/tests/cli_test.py index cc5baf9ce..7591d6fd3 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1120,6 +1120,54 @@ def test_issue_1056(runner): assert result.exit_code == 0 +@freeze_time("2015-6-1 8:00") +def test_issue_789(runner): + """an unparseable datetime range in `khal new -i` shouldn't discard the + whole event and crash, it should report the problem and let the user + retry just the datetime range + """ + runner = runner(print_new="path") + + result = runner.invoke( + main_khal, + ["new", "-i"], + "Coffee w/Somebody\n" + "tuesday 13:30 - 14:30\n" # unparseable, from the original report + "13:00 17:00\n" # retry with a range that does parse + "\n" + "None\n" + "n\n", + ) + assert "Could not parse" in result.output + assert "event saved" in result.output + assert not result.exception + assert result.exit_code == 0 + + +@freeze_time("2015-6-1 8:00") +def test_issue_789_multiple_invalid_attempts(runner): + """the retry from #789 should work for as many bad attempts as the user + makes, not just a single one + """ + runner = runner(print_new="path") + + result = runner.invoke( + main_khal, + ["new", "-i"], + "Coffee w/Somebody\n" + "tuesday 13:30 - 14:30\n" # unparseable + "still not valid input\n" # still unparseable + "13:00 17:00\n" # finally parses + "\n" + "None\n" + "n\n", + ) + assert result.output.count("Could not parse") == 2 + assert "event saved" in result.output + assert not result.exception + assert result.exit_code == 0 + + def test_list_now(runner, tmpdir): # reproduce #693 runner = runner() From 2f807668feef89cb0bcf8cddad5b8d6fd9a1a0bb Mon Sep 17 00:00:00 2001 From: John Wassilak Date: Sun, 6 Sep 2026 20:51:25 -0500 Subject: [PATCH 2/2] Improve unparseable-daterange error with a formatted example See #789 --- khal/parse_datetime.py | 11 +++++++---- tests/parse_datetime_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 7dc22f276..678415122 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -414,11 +414,14 @@ def guessrangefstr( except (ValueError, DateTimeParseError): pass + example = dt.datetime(2013, 12, 21, 21, 45).strftime(locale["datetimeformat"]) raise DateTimeParseError( - f'Could not parse "{daterange}".\nPlease check your configuration or ' - "run `khal printformats` to see if this does match your configured " - "[long](date|time|datetime)format.\nIf you suspect a bug, please " - "file an issue at https://github.com/pimutils/khal/issues/ " + f'Could not parse "{daterange}" as a daterange.\n' + f'Try formatting dates like this: "{example}".\n' + "Please check your configuration or run `khal printformats` to see " + "if this does match your configured [long](date|time|datetime)format." + "\nIf you suspect a bug, please file an issue at " + "https://github.com/pimutils/khal/issues/ " ) diff --git a/tests/parse_datetime_test.py b/tests/parse_datetime_test.py index 687ee3c7e..a411c7530 100644 --- a/tests/parse_datetime_test.py +++ b/tests/parse_datetime_test.py @@ -1,4 +1,5 @@ import datetime as dt +import re from collections import OrderedDict import pytest @@ -353,6 +354,29 @@ def test_invalid(self): with pytest.raises(DateTimeParseError): guessrangefstr("xxx yyy zzz", locale=LOCALE_BERLIN) + def test_invalid_error_includes_example(self): + """the error for an unparseable daterange should recommend a + formatted example, not just say that the input was rejected + """ + with pytest.raises(DateTimeParseError, match="Try formatting dates like this"): + guessrangefstr("xxx yyy zzz", locale=LOCALE_BERLIN) + + def test_invalid_error_example_matches_locale(self): + """the recommended example should reflect the caller's own + configured datetimeformat, not a fixed string. + """ + # same reference datetime `khal printformats` uses to demonstrate formats + reference = dt.datetime(2013, 12, 21, 21, 45) + + berlin_example = reference.strftime(LOCALE_BERLIN["datetimeformat"]) + with pytest.raises(DateTimeParseError, match=re.escape(berlin_example)): + guessrangefstr("xxx yyy zzz", locale=LOCALE_BERLIN) + + new_york_example = reference.strftime(LOCALE_NEW_YORK["datetimeformat"]) + assert berlin_example != new_york_example + with pytest.raises(DateTimeParseError, match=re.escape(new_york_example)): + guessrangefstr("xxx yyy zzz", locale=LOCALE_NEW_YORK) + @freeze_time("2016-12-30 17:53") def test_short_format_contains_year(self): """if the non long versions of date(time)format contained a year, the