From 1588c1ac22a4ec5cfa74e5b334a426575e2eb2a5 Mon Sep 17 00:00:00 2001 From: Khanh Le Date: Thu, 18 Jun 2026 05:30:17 -0500 Subject: [PATCH 1/3] Fix locale ISO datetime parsing --- AUTHORS.txt | 1 + CHANGELOG.rst | 1 + khal/parse_datetime.py | 34 +++++++++++++++++++++++++++++++++- tests/parse_datetime_test.py | 22 ++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/AUTHORS.txt b/AUTHORS.txt index 80ac4ada6..f236c7b52 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -60,3 +60,4 @@ 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 +Khanh Le diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bed684aa9..0dd18045e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,6 +19,7 @@ unreleased * FIX add timezone when assigning start/end time * FIX crash on urwid >= 4.0.5 * NEW add alarms-list output formats +* FIX parse ISO-like locale datetime formats with an empty timezone field 0.14.0 ====== diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 7dc22f276..8310bb8d4 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -24,6 +24,7 @@ import datetime as dt import logging +import locale import re from calendar import isleap from collections.abc import Callable @@ -39,6 +40,29 @@ logger = logging.getLogger("khal") +def _locale_format_candidates(dateformat: str) -> list[str]: + replacements = { + "%c": locale.D_T_FMT, + "%x": locale.D_FMT, + "%X": locale.T_FMT, + } + expanded_format = dateformat + for directive, nl_item in replacements.items(): + if directive in expanded_format: + expanded_format = expanded_format.replace(directive, locale.nl_langinfo(nl_item)) + + candidates = [dateformat] + if expanded_format != dateformat: + candidates.append(expanded_format) + + if "%Z" in expanded_format: + without_timezone = expanded_format.replace(" %Z", "").replace("%Z", "").strip() + if without_timezone not in candidates: + candidates.append(without_timezone) + + return candidates + + def timefstr(dtime_list: list[str], timeformat: str) -> dt.datetime: """converts the first item of a list (a time as a string) to a datetimeobject @@ -80,7 +104,15 @@ def datetimefstr( parts = dateformat.count(" ") + 1 dtstring = " ".join(dtime_list[0:parts]) # only time.strptime can parse the 29th of Feb. if no year is given - dtstart_struct = strptime(dtstring, dateformat) + for candidate_format in _locale_format_candidates(dateformat): + try: + dtstart_struct = strptime(dtstring, candidate_format) + except ValueError: + pass + else: + break + else: + raise ValueError if ( infer_year and dtstart_struct.tm_mon == 2 diff --git a/tests/parse_datetime_test.py b/tests/parse_datetime_test.py index 687ee3c7e..3c79e5b5b 100644 --- a/tests/parse_datetime_test.py +++ b/tests/parse_datetime_test.py @@ -1,4 +1,5 @@ import datetime as dt +import locale from collections import OrderedDict import pytest @@ -206,6 +207,27 @@ def test_short_format_contains_year(self): ["2017-1-1", "16:30"], locale=locale, default_day=dt.datetime.today() ) + def test_locale_datetime_with_empty_timezone(self, monkeypatch): + def nl_langinfo(item): + return { + locale.D_T_FMT: "%Y-%m-%dT%T %Z", + locale.D_FMT: "%Y-%m-%d", + locale.T_FMT: "%T", + }[item] + + monkeypatch.setattr(locale, "nl_langinfo", nl_langinfo) + parsed = guessdatetimefstr( + ["2026-05-14T12:00:00"], + { + "timeformat": "%X", + "dateformat": "%x", + "longdateformat": "%x", + "datetimeformat": "%c", + "longdatetimeformat": "%c", + }, + ) + assert parsed == (dt.datetime(2026, 5, 14, 12), False) + class TestGuessTimedeltafstr: def test_single(self): From f64aa8ac666782aff78d6275af156fb180150e45 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:45:44 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- khal/parse_datetime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 8310bb8d4..7a2621749 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -23,8 +23,8 @@ strings to date(time) or event objects""" import datetime as dt -import logging import locale +import logging import re from calendar import isleap from collections.abc import Callable From 60ff82d3c4dc6b812dc7f23e0c69aab94bb7e78e Mon Sep 17 00:00:00 2001 From: Khanh Le <58643031+nkgotcode@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:52:37 +0700 Subject: [PATCH 3/3] Handle empty locale timezone offsets and expanded time directives --- CHANGELOG.rst | 2 +- khal/parse_datetime.py | 14 ++++++++++---- tests/parse_datetime_test.py | 9 ++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0dd18045e..3653f5804 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ may want to subscribe to `GitHub's tag feed 0.14.2 ====== unreleased +* FIX parse ISO-like locale datetime formats with empty timezone names or offsets * FIX ``khal import --format``/``-f`` was accepted and then ignored, always falling back to ``view.event_format`` @@ -19,7 +20,6 @@ unreleased * FIX add timezone when assigning start/end time * FIX crash on urwid >= 4.0.5 * NEW add alarms-list output formats -* FIX parse ISO-like locale datetime formats with an empty timezone field 0.14.0 ====== diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 7a2621749..017eee31b 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -51,12 +51,18 @@ def _locale_format_candidates(dateformat: str) -> list[str]: if directive in expanded_format: expanded_format = expanded_format.replace(directive, locale.nl_langinfo(nl_item)) + expanded_format = re.sub( + r"%%|%T", lambda match: "%H:%M:%S" if match[0] == "%T" else match[0], expanded_format + ) + candidates = [dateformat] if expanded_format != dateformat: candidates.append(expanded_format) - if "%Z" in expanded_format: - without_timezone = expanded_format.replace(" %Z", "").replace("%Z", "").strip() + if "%Z" in expanded_format or "%z" in expanded_format: + without_timezone = re.sub( + r"%%| ?%[Zz]", lambda match: "%%" if match[0] == "%%" else "", expanded_format + ).strip() if without_timezone not in candidates: candidates.append(without_timezone) @@ -101,10 +107,10 @@ def datetimefstr( now = dt.datetime.now() if default_day is None: default_day = now.date() - parts = dateformat.count(" ") + 1 - dtstring = " ".join(dtime_list[0:parts]) # only time.strptime can parse the 29th of Feb. if no year is given for candidate_format in _locale_format_candidates(dateformat): + parts = candidate_format.count(" ") + 1 + dtstring = " ".join(dtime_list[:parts]) try: dtstart_struct = strptime(dtstring, candidate_format) except ValueError: diff --git a/tests/parse_datetime_test.py b/tests/parse_datetime_test.py index 3c79e5b5b..edb9ed9a9 100644 --- a/tests/parse_datetime_test.py +++ b/tests/parse_datetime_test.py @@ -207,17 +207,19 @@ def test_short_format_contains_year(self): ["2017-1-1", "16:30"], locale=locale, default_day=dt.datetime.today() ) - def test_locale_datetime_with_empty_timezone(self, monkeypatch): + @pytest.mark.parametrize("timezone_directive", ["%Z", "%z"]) + def test_locale_datetime_with_empty_timezone(self, monkeypatch, timezone_directive): def nl_langinfo(item): return { - locale.D_T_FMT: "%Y-%m-%dT%T %Z", + locale.D_T_FMT: f"%Y-%m-%dT%T {timezone_directive}", locale.D_FMT: "%Y-%m-%d", locale.T_FMT: "%T", }[item] monkeypatch.setattr(locale, "nl_langinfo", nl_langinfo) + words = ["2026-05-14T12:00:00", "meeting"] parsed = guessdatetimefstr( - ["2026-05-14T12:00:00"], + words, { "timeformat": "%X", "dateformat": "%x", @@ -227,6 +229,7 @@ def nl_langinfo(item): }, ) assert parsed == (dt.datetime(2026, 5, 14, 12), False) + assert words == ["meeting"] class TestGuessTimedeltafstr: