diff --git a/khard/contacts.py b/khard/contacts.py index 9b20aef0..51252686 100644 --- a/khard/contacts.py +++ b/khard/contacts.py @@ -33,7 +33,7 @@ from . import address_book # pylint: disable=unused-import # for type checking from . import helpers from .helpers.typing import (Date, PostAddress, StrList, convert_to_vcard, - list_to_string, string_to_date, string_to_list) + list_to_string, string_to_date, string_to_list, DEFAULT_YEAR) from .query import AnyQuery, Query @@ -450,7 +450,7 @@ def _prepare_birthday_value(self, date: Date) -> tuple[Optional[str], return date.strip(), True return None, False tz = date.tzname() - if date.year == 1900 and date.month != 0 and date.day != 0 \ + if date.year == DEFAULT_YEAR and date.month != 0 and date.day != 0 \ and date.hour == 0 and date.minute == 0 and date.second == 0 \ and self.version == "4.0": fmt = '--%m%d' @@ -977,7 +977,7 @@ def _format_date_object(date: Optional[Date], localize: bool) -> str: return "" if isinstance(date, str): return date - if date.year == 1900 and date.month != 0 and date.day != 0 \ + if date.year == DEFAULT_YEAR and date.month != 0 and date.day != 0 \ and date.hour == 0 and date.minute == 0 and date.second == 0: return date.strftime("--%m-%d") tz = date.tzname() @@ -1075,8 +1075,8 @@ def _set_date(self, target: str, key: str, data: dict) -> None: "with vcard version 4.0.") if re.match(r"^--\d\d-?\d\d$", new) and self.version != "4.0": raise ValueError( - f"{key} format --mm-dd and --mmdd only usable with " - "vcard version 4.0. You may use 1900 as placeholder, if " + f"{key} format --mm-dd and --mmdd only usable with vcard " + f"version 4.0. You may use {DEFAULT_YEAR} as placeholder, if " "the year is unknown.") try: v2 = string_to_date(new) diff --git a/khard/helpers/__init__.py b/khard/helpers/__init__.py index 303c8059..bc31a21b 100644 --- a/khard/helpers/__init__.py +++ b/khard/helpers/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Optional, Sequence, Union from ruamel.yaml.scalarstring import LiteralScalarString -from .typing import list_to_string, PostAddress +from .typing import list_to_string, PostAddress, DEFAULT_YEAR YamlPostAddresses = dict[str, Union[list[dict[str, Any]], dict[str, Any]]] @@ -164,7 +164,7 @@ def yaml_anniversary(anniversary: Union[str, datetime, None], return None if isinstance(anniversary, datetime): - if (version == "4.0" and anniversary.year == 1900 + if (version == "4.0" and anniversary.year == DEFAULT_YEAR and anniversary.month != 0 and anniversary.day != 0 and anniversary.hour == 0 diff --git a/khard/helpers/typing.py b/khard/helpers/typing.py index 7e0e363d..487407bc 100644 --- a/khard/helpers/typing.py +++ b/khard/helpers/typing.py @@ -10,6 +10,14 @@ PostAddress = dict[str, str] +# A default year for datetimes without one. Python does not support datetime +# objects without a year from 3.15 onwards but vCard does. We add a default +# year to the python object and detect it again when formatting the object. +# 1900 was the internally used default year of python's datetime object when it +# still did support instances without a year (before python 3.5). +DEFAULT_YEAR = 1900 + + @overload def convert_to_vcard(name: str, value: StrList, constraint: type[str]) -> str: ... @overload @@ -67,20 +75,44 @@ def string_to_date(string: str) -> datetime: :param string: the date string to parse :returns: the parsed datetime object """ - # try date formats --mmdd, --mm-dd, yyyymmdd, yyyy-mm-dd and datetime - # formats yyyymmddThhmmss, yyyy-mm-ddThh:mm:ss, yyyymmddThhmmssZ, - # yyyy-mm-ddThh:mm:ssZ. - for fmt in ("--%m%d", "--%m-%d", "%Y%m%d", "%Y-%m-%d", "%Y%m%dT%H%M%S", - "%Y-%m-%dT%H:%M:%S", "%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ"): + + # Attempt to parse the string as any of the date and time formats supported + # by Khard, as defined by the vCard and ISO 8601:2000 specifications. + # Strings which define a day-of-month but not a year are ambiguous, require + # special handling, and will be unsupported in Python >= 3.15. (They were + # already removed in ISO 8601:2004, but remain a part of vCard.) + + # Ambiguous cases of a date with no year (--%m%d and --%m-%d). + try: + if string.startswith("--"): + tmp = str(DEFAULT_YEAR) + string[2:] + if "-" in string[2:]: + return datetime.strptime(tmp, "%Y%m-%d") + else: + return datetime.strptime(tmp, "%Y%m%d") + except ValueError: + pass + + # Fully qualified date and time formats. + for fmt in ( + "%Y%m%d", + "%Y-%m-%d", + "%Y%m%dT%H%M%S", + "%Y-%m-%dT%H:%M:%S", + "%Y%m%dT%H%M%SZ", + "%Y-%m-%dT%H:%M:%SZ", + ): try: return datetime.strptime(string, fmt) except ValueError: - continue # with the next format - # try datetime formats yyyymmddThhmmsstz and yyyy-mm-ddThh:mm:sstz where tz - # may look like -06:00. + continue + + # Timezone formats which may contain a problematic colon. for fmt in ("%Y%m%dT%H%M%S%z", "%Y-%m-%dT%H:%M:%S%z"): try: - return datetime.strptime(''.join(string.rsplit(":", 1)), fmt) + return datetime.strptime("".join(string.rsplit(":", 1)), fmt) except ValueError: - continue # with the next format + continue + + # All formats tried. Date cannot be parsed. raise ValueError