Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-khard for openSUSE:Factory checked in at 2026-06-28 21:07:15 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-khard (Old) and /work/SRC/openSUSE:Factory/.python-khard.new.11887 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-khard" Sun Jun 28 21:07:15 2026 rev:5 rq:1362045 version:0.21.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-khard/python-khard.changes 2026-03-11 20:53:55.934511357 +0100 +++ /work/SRC/openSUSE:Factory/.python-khard.new.11887/python-khard.changes 2026-06-28 21:07:55.384882215 +0200 @@ -1,0 +2,10 @@ +Sat Jun 27 20:55:14 UTC 2026 - Dirk Müller <[email protected]> + +- update to 0.21.0: + * Remove support for python 3.9 + * Fix bug skipping config's skip_unparsable setting (#355) + * Fix special handling for ambiguous date formats (#349) + * Add failing test for partial date with leap day + * Remove the autodoc typehints extension in sphinx + +------------------------------------------------------------------- Old: ---- khard-0.20.1.tar.gz New: ---- khard-0.21.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-khard.spec ++++++ --- /var/tmp/diff_new_pack.odCmA3/_old 2026-06-28 21:07:56.364915237 +0200 +++ /var/tmp/diff_new_pack.odCmA3/_new 2026-06-28 21:07:56.364915237 +0200 @@ -18,29 +18,28 @@ %{?sle15_python_module_pythons} Name: python-khard -Version: 0.20.1 +Version: 0.21.0 Release: 0 Summary: Console carddav client License: GPL-3.0-only URL: https://github.com/lucc/khard Source0: https://files.pythonhosted.org/packages/source/k/khard/khard-%{version}.tar.gz BuildArch: noarch -BuildRequires: %{python_module base >= 3.9} +BuildRequires: %{python_module base >= 3.10} BuildRequires: %{python_module pip} -BuildRequires: %{python_module setuptools_scm} +BuildRequires: %{python_module setuptools >= 61.0} +BuildRequires: %{python_module setuptools_scm >= 6.2} BuildRequires: %{python_module wheel} BuildRequires: fdupes BuildRequires: python-rpm-macros # SECTION test -BuildRequires: %{python_module configobj} -BuildRequires: %{python_module ruamel.yaml} -BuildRequires: %{python_module vobject} +BuildRequires: %{python_module configobj >= 5.0.6} +BuildRequires: %{python_module ruamel.yaml >= 0.17.0} +BuildRequires: %{python_module vobject >= 0.9.7} # /SECTION -Requires: python-Unidecode -Requires: python-atomicwrites -Requires: python-configobj -Requires: python-ruamel.yaml -Requires: python-vobject +Requires: python-configobj >= 5.0.6 +Requires: python-ruamel.yaml >= 0.17.0 +Requires: python-vobject >= 0.9.7 Requires(post): update-alternatives Requires(postun): update-alternatives Suggests: python3-vdirsyncer ++++++ khard-0.20.1.tar.gz -> khard-0.21.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/.github/workflows/ci.yml new/khard-0.21.0/.github/workflows/ci.yml --- old/khard-0.20.1/.github/workflows/ci.yml 2026-01-01 12:13:12.000000000 +0100 +++ new/khard-0.21.0/.github/workflows/ci.yml 2026-06-15 15:13:21.000000000 +0200 @@ -9,7 +9,7 @@ strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/CHANGES new/khard-0.21.0/CHANGES --- old/khard-0.20.1/CHANGES 2026-01-01 12:29:56.000000000 +0100 +++ new/khard-0.21.0/CHANGES 2026-06-15 15:39:35.000000000 +0200 @@ -1,6 +1,15 @@ Change Log ========== +v0.21.0 2026-06-15 + +- Remove support for python 3.9 +- Fix bug skipping config's skip_unparsable setting (#355) +- Fix special handling for ambiguous date formats (#349) +- Add failing test for partial date with leap day +- Remove the autodoc typehints extension in sphinx + + v0.20.1 2026-01-01 - Fix reopening of stdin on Windows (#347) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/PKG-INFO new/khard-0.21.0/PKG-INFO --- old/khard-0.20.1/PKG-INFO 2026-01-01 12:32:15.607624500 +0100 +++ new/khard-0.21.0/PKG-INFO 2026-06-15 15:42:00.077532300 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: khard -Version: 0.20.1 +Version: 0.21.0 Summary: A console address book manager Author-email: Eric Scheibler <[email protected]> License: GPL @@ -17,7 +17,7 @@ Classifier: Intended Audience :: End Users/Desktop Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3 :: Only -Requires-Python: >=3.9 +Requires-Python: >=3.10 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: configobj==5.*,>=5.0.6 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/doc/source/conf.py new/khard-0.21.0/doc/source/conf.py --- old/khard-0.20.1/doc/source/conf.py 2025-07-28 22:47:18.000000000 +0200 +++ new/khard-0.21.0/doc/source/conf.py 2026-06-15 15:13:21.000000000 +0200 @@ -49,10 +49,9 @@ # ones. extensions = [ 'autoapi.extension', # https://sphinx-autoapi.readthedocs.io/en/latest/ - 'sphinx.ext.autodoc', # https://pypi.org/project/sphinx-autodoc-typehints/ + 'sphinx.ext.autodoc', # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html 'sphinx.ext.autosectionlabel', 'sphinx.ext.todo', - 'sphinx_autodoc_typehints', # https://pypi.org/project/sphinx-autodoc-typehints/ 'sphinxarg.ext', ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/doc/source/man/khard.conf.rst new/khard-0.21.0/doc/source/man/khard.conf.rst --- old/khard-0.20.1/doc/source/man/khard.conf.rst 2025-05-03 09:04:23.000000000 +0200 +++ new/khard-0.21.0/doc/source/man/khard.conf.rst 2026-06-15 15:13:21.000000000 +0200 @@ -38,14 +38,20 @@ The config file consists of these four sections: addressbooks - This section contains several subsections, but at least one. Each subsection - can have an arbitrary name which will be the name of an addressbook known to - khard. Each of these subsections **must** have a *path* key with the path to - the folder containing the vCard files for that addressbook. Optionally, you - can set the *type* value to either ``discover`` or ``vdir``, the default. The - *path* value supports environment variables and tilde prefixes. When using - the ``discover`` type, it also supports globbing. :program:`khard` expects - the vCard files to hold only one VCARD record each and end in a :file:`.vcf` + This section describes the addressbooks that are known to :program:`khard`. + It contains several subsections, but at least one. Each subsection can have + an arbitrary title and **must** have a *path* key and may have a *type* key. + The optional *type* can be either ``discover`` or ``vdir`` (which is the + default). A subsection with type ``vdir`` describes a single addressbook. + In this case the title of the subsection is the name of the addressbook as + it is referenced in khard. With a type of ``discover`` the subsection might + describes several addressbooks which are discover by globbing. The name of + each addressbook will be the basename of the folder found via globbing. The + *path* key contains the path to the folder containing the vCard files for + that addressbook. The *path* value supports environment variables and tilde + prefixes. When using the ``discover`` type, it also supports globbing (for + the exact syntax see :module:`glob`). :program:`khard` expects the vCard + files to hold only one VCARD record each and end in a :file:`.vcf` extension. general diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/doc/source/man/khard.rst new/khard-0.21.0/doc/source/man/khard.rst --- old/khard-0.20.1/doc/source/man/khard.rst 2025-07-28 22:47:18.000000000 +0200 +++ new/khard-0.21.0/doc/source/man/khard.rst 2026-06-15 15:13:21.000000000 +0200 @@ -56,7 +56,7 @@ Detailed display ~~~~~~~~~~~~~~~~ -These subcommands display detailed information about one subcommand. +These subcommands display detailed information about one contact. show display detailed information about one contact, supported output formats diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/flake.lock new/khard-0.21.0/flake.lock --- old/khard-0.20.1/flake.lock 2026-01-01 09:07:14.000000000 +0100 +++ new/khard-0.21.0/flake.lock 2026-06-15 15:13:21.000000000 +0200 @@ -2,6 +2,22 @@ "nodes": { "nixpkgs": { "locked": { + "lastModified": 1774106199, + "narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-python310": { + "locked": { "lastModified": 1767116409, "narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=", "owner": "NixOS", @@ -11,8 +27,8 @@ }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", "repo": "nixpkgs", + "rev": "cad22e7d996aea55ecab064e84834289143e44a0", "type": "github" } }, @@ -23,11 +39,11 @@ ] }, "locked": { - "lastModified": 1764134915, - "narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=", + "lastModified": 1773909723, + "narHash": "sha256-HmcZQ/hMPHR22Ri/6Sl7Z0B5J8nZa9bRnZJtDFInM7I=", "owner": "pyproject-nix", "repo": "pyproject.nix", - "rev": "2c8df1383b32e5443c921f61224b198a2282a657", + "rev": "d37dcf34ac7194eac4b0d10520d01298c434267d", "type": "github" }, "original": { @@ -39,6 +55,7 @@ "root": { "inputs": { "nixpkgs": "nixpkgs", + "nixpkgs-python310": "nixpkgs-python310", "pyproject-nix": "pyproject-nix" } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/flake.nix new/khard-0.21.0/flake.nix --- old/khard-0.20.1/flake.nix 2026-01-01 12:12:25.000000000 +0100 +++ new/khard-0.21.0/flake.nix 2026-06-15 15:27:49.000000000 +0200 @@ -1,11 +1,15 @@ { description = "Development flake for khard"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # keep an old version of nixpkgs for ci until we drop python 3.10 support + # https://github.com/NixOS/nixpkgs/issues/488818 + inputs.nixpkgs-python310.url = "github:NixOS/nixpkgs/cad22e7d996aea55ecab064e84834289143e44a0"; inputs.pyproject-nix.url = "github:pyproject-nix/pyproject.nix"; inputs.pyproject-nix.inputs.nixpkgs.follows = "nixpkgs"; outputs = { self, nixpkgs, + nixpkgs-python310, pyproject-nix, }: let project = pyproject-nix.lib.project.loadPyproject {projectRoot = ./.;}; @@ -34,7 +38,7 @@ # see https://github.com/scheibler/khard/issues/263 preCheck = "export COLUMNS=80"; pythonImportsCheck = ["khard"]; - pytestFlagsArray = ["-s"]; + pytestFlags = ["-s"]; }; in python3.pkgs.buildPythonApplication (attrs // overrides); @@ -54,6 +58,7 @@ p.build p.mypy p.pylint + p.pytest ] ++ (upstream p)); packages = with pkgs; [git ruff pythonEnv]; @@ -77,7 +82,7 @@ }; checks.${system} = { inherit default; - tests-python-310 = tests pkgs.python310; + tests-python-310 = tests (import nixpkgs-python310 {inherit system;}).python310; tests-python-311 = tests pkgs.python311; tests-python-312 = tests pkgs.python312; tests-python-313 = tests pkgs.python313; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/actions.py new/khard-0.21.0/khard/actions.py --- old/khard-0.20.1/khard/actions.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/actions.py 2026-06-15 15:13:21.000000000 +0200 @@ -1,6 +1,6 @@ """Names and aliases for the subcommands on the command line""" -from typing import Generator, Iterable, Optional +from typing import Generator, Iterable class Actions: @@ -28,7 +28,7 @@ } @classmethod - def get_action(cls, alias: str) -> Optional[str]: + def get_action(cls, alias: str) -> str | None: """Find the name of the action for the supplied alias. If no action is associated with the given alias, None is returned. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/address_book.py new/khard-0.21.0/khard/address_book.py --- old/khard-0.20.1/khard/address_book.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/address_book.py 2026-06-15 15:13:21.000000000 +0200 @@ -6,7 +6,7 @@ import glob import logging import os -from typing import Generator, Iterator, Optional, Union, overload +from typing import Generator, Iterator, overload import vobject.base @@ -25,8 +25,7 @@ """:param name: the name to identify the address book""" self._loaded = False self.contacts: dict[str, "contacts.Contact"] = {} - self._short_uids: Optional[dict[str, - "contacts.Contact"]] = None + self._short_uids: "dict[str, contacts.Contact] | None" = None self.name = name def __str__(self) -> str: @@ -136,7 +135,7 @@ """ def __init__(self, name: str, path: str, - private_objects: Optional[list[str]] = None, + private_objects: list[str] | None = None, localize_dates: bool = True, skip: bool = False) -> None: """ :param name: the name to identify the address book @@ -252,11 +251,11 @@ len(self.contacts), self.name) @overload - def __getitem__(self, key: Union[int, str]) -> VdirAddressBook: ... + def __getitem__(self, key: int | str) -> VdirAddressBook: ... @overload def __getitem__(self, key: slice) -> list[VdirAddressBook]: ... - def __getitem__(self, key: Union[int, str, slice] - ) -> Union[VdirAddressBook, list[VdirAddressBook]]: + def __getitem__(self, key: int | str | slice + ) -> VdirAddressBook | list[VdirAddressBook]: """Get one or more of the backing address books by name or index :param key: the name of the address book to get or its index diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/cli.py new/khard-0.21.0/khard/cli.py --- old/khard-0.20.1/khard/cli.py 2026-01-01 08:53:53.000000000 +0100 +++ new/khard-0.21.0/khard/cli.py 2026-06-15 15:13:21.000000000 +0200 @@ -65,9 +65,9 @@ description="Khard is a vcard address book for the console", formatter_class=argparse.RawTextHelpFormatter, add_help=False) base.add_argument("-c", "--config", help="config file to use") - base.add_argument("--debug", action="store_true", + base.add_argument("--debug", action="store_true", default=None, help="enable debug output") - base.add_argument("--skip-unparsable", action="store_true", + base.add_argument("--skip-unparsable", action="store_true", default=None, help="skip unparsable vcard files") base.add_argument("-v", "--version", action="version", version="Khard version {}".format(khard_version)) @@ -139,10 +139,10 @@ choices=("first_name", "last_name", "formatted_name"), help="Display names in contact table by first or last name") sort_parser.add_argument( - "-g", "--group-by-addressbook", action="store_true", + "-g", "--group-by-addressbook", action="store_true", default=None, help="Group contact table by address book") sort_parser.add_argument( - "-r", "--reverse", action="store_true", + "-r", "--reverse", action="store_true", default=None, help="Reverse order of contact table") sort_parser.add_argument( "-s", "--sort", choices=("first_name", "last_name", "formatted_name"), @@ -151,7 +151,7 @@ # create search subparsers default_search_parser = argparse.ArgumentParser(add_help=False) default_search_parser.add_argument( - "-f", "--search-in-source-files", action="store_true", + "-f", "--search-in-source-files", action="store_true", default=None, help="Look into source vcf files to speed up search queries in " "large address books. Beware that this option could lead " "to incomplete results.") @@ -161,7 +161,7 @@ "contact") merge_search_parser = argparse.ArgumentParser(add_help=False) merge_search_parser.add_argument( - "-f", "--search-in-source-files", action="store_true", + "-f", "--search-in-source-files", action="store_true", default=None, help="Look into source vcf files to speed up search queries in " "large address books. Beware that this option could lead " "to incomplete results.") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/config.py new/khard-0.21.0/khard/config.py --- old/khard-0.20.1/khard/config.py 2025-05-03 09:04:23.000000000 +0200 +++ new/khard-0.21.0/khard/config.py 2026-06-15 15:13:21.000000000 +0200 @@ -7,7 +7,7 @@ import os import re import shlex -from typing import Iterable, Optional, Union +from typing import Iterable from glob import iglob import configobj @@ -27,7 +27,7 @@ # This is the type of the config file parameter accepted by the configobj # library: # https://configobj.readthedocs.io/en/latest/configobj.html#reading-a-config-file -ConfigFile = Union[str, list[str], io.StringIO] +ConfigFile = str | list[str] | io.StringIO def validate_command(value: list[str]) -> list[str]: @@ -90,7 +90,7 @@ supported_vcard_versions = ("3.0", "4.0") - def __init__(self, config_file: Optional[ConfigFile] = None) -> None: + def __init__(self, config_file: ConfigFile | None = None) -> None: self.config: configobj.ConfigObj self.abooks: AddressBookCollection locale.setlocale(locale.LC_ALL, '') @@ -101,7 +101,7 @@ self._set_attributes() @classmethod - def _load_config_file(cls, config_file: Optional[ConfigFile] + def _load_config_file(cls, config_file: ConfigFile | None ) -> configobj.ConfigObj: """Find and load the config file. @@ -258,7 +258,7 @@ abook.load(queries[abook.name], self.search_in_source_files) return collection - def merge(self, other: Union[configobj.ConfigObj, dict]) -> None: + def merge(self, other: configobj.ConfigObj | dict) -> None: """Merge the config with some other dict or ConfigObj :param other: the other dict or ConfigObj to merge into self diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/contacts.py new/khard-0.21.0/khard/contacts.py --- old/khard-0.20.1/khard/contacts.py 2025-07-22 23:17:03.000000000 +0200 +++ new/khard-0.21.0/khard/contacts.py 2026-06-15 15:13:34.000000000 +0200 @@ -21,8 +21,8 @@ import re import tempfile import time -from typing import Any, Callable, IO, Iterator, Literal, Optional, TypeVar, \ - Union, Sequence, overload +from typing import Any, Callable, IO, Iterator, Literal, TypeVar, Sequence, \ + overload from ruamel import yaml from ruamel.yaml import YAML @@ -33,13 +33,13 @@ 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 logger = logging.getLogger(__name__) T = TypeVar("T") -LabeledStrs = list[Union[str, dict[str, str]]] +LabeledStrs = list[str | dict[str, str]] @overload @@ -47,8 +47,8 @@ @overload def multi_property_key(item: dict[T, Any]) -> tuple[Literal[1], T]: ... @overload -def multi_property_key(item: Union[str, dict[T, Any]]) -> tuple[Literal[0, 1], Union[T, str]]: ... -def multi_property_key(item: Union[str, dict[T, Any]]) -> tuple[Literal[0, 1], Union[T, str]]: +def multi_property_key(item: str | dict[T, Any]) -> tuple[Literal[0, 1], T | str]: ... +def multi_property_key(item: str | dict[T, Any]) -> tuple[Literal[0, 1], T | str]: """Key function to pass to sorted(), allowing sorting of dicts with lists and strings. Dicts will be sorted by their label, after other types. @@ -92,7 +92,7 @@ address_types_v4 = ("home", "work") def __init__(self, vcard: vobject.base.Component, - version: Optional[str] = None) -> None: + version: str | None = None) -> None: """Initialize the wrapper around the given vcard. :param vcard: the vCard to wrap @@ -113,13 +113,12 @@ return self.formatted_name @overload - def get_first(self, property: Literal["n"]) -> Optional[vobject.vcard.Name]: ... + def get_first(self, property: Literal["n"]) -> vobject.vcard.Name | None: ... @overload - def get_first(self, property: Literal["adr"]) -> Optional[vobject.vcard.Address]: ... + def get_first(self, property: Literal["adr"]) -> vobject.vcard.Address | None: ... @overload - def get_first(self, property: str) -> Optional[str]: ... - def get_first(self, property: str) -> Union[None, str, vobject.vcard.Name, - vobject.vcard.Address]: + def get_first(self, property: str) -> str | None: ... + def get_first(self, property: str) -> None | str | vobject.vcard.Name | vobject.vcard.Address: """Get a property from the underlying vCard. This method should only be called for properties with cardinality \\*1 @@ -262,7 +261,7 @@ return [default_type] @property - def version(self) -> Optional[str]: + def version(self) -> str | None: return self.get_first("version") @version.setter @@ -277,7 +276,7 @@ version.value = convert_to_vcard("version", value, str) @property - def uid(self) -> Optional[str]: + def uid(self) -> str | None: return self.get_first("uid") @uid.setter @@ -300,7 +299,7 @@ rev.value = datetime.datetime.now().strftime("%Y%m%dT%H%M%SZ") @property - def birthday(self) -> Optional[Date]: + def birthday(self) -> Date | None: """Return the birthday as a datetime object or a string depending on whether it is of type text or not. If no birthday is present in the vcard None is returned. @@ -336,7 +335,7 @@ bday.params['VALUE'] = ['text'] @property - def anniversary(self) -> Optional[Date]: + def anniversary(self) -> Date | None: """ :returns: contacts anniversary or None if not available """ @@ -414,9 +413,9 @@ return group_name def _add_labelled_property( - self, property: str, value: StrList, label: Optional[str] = None, + self, property: str, value: StrList, label: str | None = None, name_groups: bool = False, - allowed_object_type: Union[None, type[str], type[list]] = str) -> None: + allowed_object_type: None | type[str] | type[list] = str) -> None: """Add an object to the VCARD. If a label is given it will be added to a group with an ABLABEL. @@ -437,7 +436,7 @@ ablabel_obj.group = group_name ablabel_obj.value = label - def _prepare_birthday_value(self, date: Date) -> tuple[Optional[str], + def _prepare_birthday_value(self, date: Date) -> tuple[str | None, bool]: """Prepare a value to be stored in a BDAY or ANNIVERSARY attribute. @@ -450,7 +449,7 @@ 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' @@ -580,13 +579,13 @@ return self.formatted_name @property - def first_name(self) -> Optional[str]: + def first_name(self) -> str | None: if parts := self._get_first_names(): return list_to_string(parts, " ") return None @property - def last_name(self) -> Optional[str]: + def last_name(self) -> str | None: if parts := self._get_last_names(): return list_to_string(parts, " ") return None @@ -611,13 +610,13 @@ suffix=convert_to_vcard("name suffix", suffix, None)) @property - def organisations(self) -> list[Union[list[str], dict[str, list[str]]]]: + def organisations(self) -> list[list[str] | dict[str, list[str]]]: """ :returns: list of organisations, sorted alphabetically """ return self.get_all("org") - def _add_organisation(self, organisation: StrList, label: Optional[str] = None) -> None: + def _add_organisation(self, organisation: StrList, label: str | None = None) -> None: """Add one ORG entry to the underlying vcard :param organisation: the value to add @@ -640,39 +639,39 @@ def titles(self) -> LabeledStrs: return self.get_all("title") - def _add_title(self, title: str, label: Optional[str] = None) -> None: + def _add_title(self, title: str, label: str | None = None) -> None: self._add_labelled_property("title", title, label, True) @property def roles(self) -> LabeledStrs: return self.get_all("role") - def _add_role(self, role: str, label: Optional[str] = None) -> None: + def _add_role(self, role: str, label: str | None = None) -> None: self._add_labelled_property("role", role, label, True) @property def nicknames(self) -> LabeledStrs: return self.get_all("nickname") - def _add_nickname(self, nickname: str, label: Optional[str] = None) -> None: + def _add_nickname(self, nickname: str, label: str | None = None) -> None: self._add_labelled_property("nickname", nickname, label, True) @property def notes(self) -> LabeledStrs: return self.get_all("note") - def _add_note(self, note: str, label: Optional[str] = None) -> None: + def _add_note(self, note: str, label: str | None = None) -> None: self._add_labelled_property("note", note, label, True) @property def webpages(self) -> LabeledStrs: return self.get_all("url") - def _add_webpage(self, webpage: str, label: Optional[str] = None) -> None: + def _add_webpage(self, webpage: str, label: str | None = None) -> None: self._add_labelled_property("url", webpage, label, True) @property - def categories(self) -> Union[list[str], list[list[str]]]: + def categories(self) -> list[str] | list[list[str]]: category_list = self.get_all("categories") if not category_list: return category_list @@ -920,8 +919,8 @@ """Conversion of vcards to YAML and updating the vcard from YAML""" def __init__(self, vcard: vobject.base.Component, - supported_private_objects: Optional[list[str]] = None, - version: Optional[str] = None, localize_dates: bool = False + supported_private_objects: list[str] | None = None, + version: str | None = None, localize_dates: bool = False ) -> None: """Initialize attributes needed for yaml conversions @@ -972,12 +971,12 @@ ####################### @staticmethod - def _format_date_object(date: Optional[Date], localize: bool) -> str: + def _format_date_object(date: Date | None, localize: bool) -> str: if not date: 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() @@ -1029,8 +1028,8 @@ return contact_data @staticmethod - def _set_string_list(setter: Callable[[str, Optional[str]], None], - key: str, data: dict[str, Union[str, list[str]]] + def _set_string_list(setter: Callable[[str, str | None], None], + key: str, data: dict[str, str | list[str]] ) -> None: """Pre-process a string or list and set each value with the given setter @@ -1075,8 +1074,8 @@ "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) @@ -1376,8 +1375,8 @@ def __init__(self, vcard: vobject.base.Component, address_book: "address_book.VdirAddressBook", filename: str, - supported_private_objects: Optional[list[str]] = None, - vcard_version: Optional[str] = None, + supported_private_objects: list[str] | None = None, + vcard_version: str | None = None, localize_dates: bool = False) -> None: """Initialize the vcard object. @@ -1403,8 +1402,8 @@ @classmethod def new(cls, address_book: "address_book.VdirAddressBook", - supported_private_objects: Optional[list[str]] = None, - version: Optional[str] = None, localize_dates: bool = False + supported_private_objects: list[str] | None = None, + version: str | None = None, localize_dates: bool = False ) -> "Contact": """Create a new Contact from scratch""" vcard = vobject.vCard() @@ -1418,8 +1417,8 @@ @classmethod def from_file(cls, address_book: "address_book.VdirAddressBook", filename: str, query: Query = AnyQuery(), - supported_private_objects: Optional[list[str]] = None, - localize_dates: bool = False) -> Optional["Contact"]: + supported_private_objects: list[str] | None = None, + localize_dates: bool = False) -> "Contact | None": """Load a Contact object from a .vcf file if the plain file matches the query. @@ -1450,8 +1449,8 @@ @classmethod def from_yaml(cls, address_book: "address_book.VdirAddressBook", yaml: str, - supported_private_objects: Optional[list[str]] = None, - version: Optional[str] = None, localize_dates: bool = False + supported_private_objects: list[str] | None = None, + version: str | None = None, localize_dates: bool = False ) -> "Contact": """Use this if you want to create a new contact from user input.""" contact = cls.new(address_book, supported_private_objects, version, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/helpers/__init__.py new/khard-0.21.0/khard/helpers/__init__.py --- old/khard-0.20.1/khard/helpers/__init__.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/helpers/__init__.py 2026-06-15 15:13:34.000000000 +0200 @@ -4,13 +4,14 @@ import pathlib import random import string -from typing import Any, Optional, Sequence, Union +from typing import Any, Sequence 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]]] +YamlPostAddresses = dict[str, list[dict[str, Any]] | dict[str, Any]] +YAML = str | Sequence | dict[str, Any] | None def pretty_print(table: list[list[str]], justify: str = "L") -> str: @@ -66,9 +67,7 @@ for _ in range(36)]) -def yaml_clean(value: Union[str, Sequence, dict[str, Any], None] - ) -> Union[Sequence, str, dict[str, Any], LiteralScalarString, - None]: +def yaml_clean(value: YAML) -> YAML | LiteralScalarString: """ sanitize yaml values according to some simple principles: 1. empty values are none, so ruamel does not print an empty list/str @@ -95,9 +94,9 @@ def yaml_dicts( - data: Optional[dict[str, Any]], - defaults: Union[dict[str, Any], list[str], None] = None - ) -> Optional[dict[str, Any]]: + data: dict[str, Any] | None, + defaults: dict[str, Any] | list[str] | None = None + ) -> dict[str, Any] | None: """ format a dict according to template, if empty use specified defaults @@ -118,10 +117,10 @@ return data_dict -def yaml_addresses(addresses: Optional[dict[str, list[PostAddress]]], +def yaml_addresses(addresses: dict[str, list[PostAddress]] | None, address_properties: list[str], - defaults: Optional[list[str]] = None - ) -> Optional[YamlPostAddresses]: + defaults: list[str] | None = None + ) -> YamlPostAddresses | None: """ build a dict from an address, using a list of properties, an address has. @@ -151,8 +150,8 @@ return address_dict -def yaml_anniversary(anniversary: Union[str, datetime, None], - version: str) -> Optional[str]: +def yaml_anniversary(anniversary: str | datetime | None, + version: str) -> str | None: """ format an anniversary according to its contents and the vCard version. @@ -164,7 +163,7 @@ 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 @@ -185,7 +184,7 @@ return anniversary -def convert_to_yaml(name: str, value: Union[None, str, list], indentation: int, +def convert_to_yaml(name: str, value: None | str | list, indentation: int, index_of_colon: int, show_multi_line_character: bool ) -> list[str]: """converts a value list into yaml syntax @@ -250,7 +249,7 @@ return strings -def indent_multiline_string(input: Union[str, list], indentation: int, +def indent_multiline_string(input: str | list, indentation: int, show_multi_line_character: bool) -> str: # if input is a list, convert to string first if isinstance(input, list): @@ -265,7 +264,7 @@ def get_new_contact_template( - supported_private_objects: Optional[list[str]] = None) -> str: + supported_private_objects: list[str] | None = None) -> str: formatted_private_objects = [] if supported_private_objects: formatted_private_objects.append("") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/helpers/interactive.py new/khard-0.21.0/khard/helpers/interactive.py --- old/khard-0.20.1/khard/helpers/interactive.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/helpers/interactive.py 2026-06-15 15:13:21.000000000 +0200 @@ -6,7 +6,7 @@ import os.path import subprocess from tempfile import NamedTemporaryFile -from typing import Callable, Generator, Optional, Sequence, TypeVar, Union +from typing import Callable, Generator, Sequence, TypeVar from ..exceptions import Cancelled from ..contacts import Contact @@ -26,8 +26,8 @@ "no" if accept_enter_key else None) -def ask(message: str, choices: list[str], default: Optional[str] = None, - help: Optional[str] = None) -> str: +def ask(message: str, choices: list[str], default: str | None = None, + help: str | None = None) -> str: """Ask the user to select one of the given choices :param message: a text to show to the user @@ -74,7 +74,7 @@ print(help) -def select(items: Sequence[T], include_none: bool = False) -> Optional[T]: +def select(items: Sequence[T], include_none: bool = False) -> T | None: """Ask the user to select an item from a list. The list should be displayed to the user before calling this function and @@ -114,8 +114,8 @@ """Wrapper around subprocess.Popen to edit and merge files.""" - def __init__(self, editor: Union[str, list[str]], - merge_editor: Union[str, list[str]]) -> None: + def __init__(self, editor: str | list[str], + merge_editor: str | list[str]) -> None: self.editor = [editor] if isinstance(editor, str) else editor self.merge_editor = [merge_editor] if isinstance(merge_editor, str) \ else merge_editor @@ -137,7 +137,7 @@ def _mtime(filename: str) -> datetime: return datetime.fromtimestamp(os.path.getmtime(filename)) - def edit_files(self, file1: str, file2: Optional[str] = None) -> EditState: + def edit_files(self, file1: str, file2: str | None = None) -> EditState: """Edit the given files If only one file is given the timestamp of this file is checked, if two @@ -163,8 +163,8 @@ return EditState.modified def edit_templates(self, yaml2card: Callable[[str], Contact], - template1: str, template2: Optional[str] = None - ) -> Optional[Contact]: + template1: str, template2: str | None = None + ) -> Contact | None: """Edit YAML templates of contacts and parse them back :param yaml2card: a function to convert the modified YAML templates diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/helpers/typing.py new/khard-0.21.0/khard/helpers/typing.py --- old/khard-0.20.1/khard/helpers/typing.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/helpers/typing.py 2026-06-15 15:13:34.000000000 +0200 @@ -1,22 +1,30 @@ """Helper code for type annotations and runtime type conversion.""" from datetime import datetime -from typing import Union, overload +from typing import overload # some type aliases -Date = Union[str, datetime] -StrList = Union[str, list[str]] +Date = str | datetime +StrList = str | list[str] 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 def convert_to_vcard(name: str, value: StrList, constraint: type[list]) -> list[str]: ... @overload def convert_to_vcard(name: str, value: StrList, constraint: None) -> StrList: ... -def convert_to_vcard(name: str, value: StrList, constraint: Union[None, type[str], type[list]]) -> StrList: +def convert_to_vcard(name: str, value: StrList, constraint: None | type[str] | type[list]) -> StrList: """converts user input into vCard compatible data structures :param name: object name, only required for error messages @@ -42,7 +50,7 @@ raise ValueError(f"{name} must be a string or a list with strings.") -def list_to_string(input: Union[str, list], delimiter: str) -> str: +def list_to_string(input: str | list, delimiter: str) -> str: """converts list to string recursively so that nested lists are supported :param input: a list of strings and lists of strings (and so on recursive) @@ -55,7 +63,7 @@ return input -def string_to_list(input: Union[str, list[str]], delimiter: str) -> list[str]: +def string_to_list(input: str | list[str], delimiter: str) -> list[str]: if isinstance(input, list): return input return [x.strip() for x in input.split(delimiter)] @@ -67,20 +75,44 @@ :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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/khard.py new/khard-0.21.0/khard/khard.py --- old/khard-0.20.1/khard/khard.py 2026-01-01 12:25:50.000000000 +0100 +++ new/khard-0.21.0/khard/khard.py 2026-06-15 15:13:21.000000000 +0200 @@ -11,7 +11,7 @@ import os import sys import textwrap -from typing import cast, Callable, Iterable, Optional, Union +from typing import cast, Callable, Iterable from . import helpers from .address_book import AddressBookCollection, VdirAddressBook @@ -29,7 +29,7 @@ logger = logging.getLogger(__name__) config: Config # the types that sys.exit() can digest -ExitStatus = Union[str, int, None] +ExitStatus = str | int | None def version_check(contact: Contact, description: str) -> bool: @@ -152,8 +152,8 @@ contact.address_book, target_address_book)) -def list_address_books(address_books: Union[AddressBookCollection, - list[VdirAddressBook]]) -> None: +def list_address_books(address_books: + AddressBookCollection | list[VdirAddressBook]) -> None: table = [["Index", "Address book"]] for index, address_book in enumerate(address_books, 1): table.append([cast(str, index), address_book.name]) @@ -231,9 +231,9 @@ print(helpers.pretty_print(table)) -def choose_address_book_from_list(header: str, abooks: Union[ - AddressBookCollection, list[VdirAddressBook]] - ) -> Optional[VdirAddressBook]: +def choose_address_book_from_list(header: str, abooks: + AddressBookCollection | list[VdirAddressBook] + ) -> VdirAddressBook | None: """Let the user select one of the given address books :param header: some text to print in front of the list @@ -252,7 +252,7 @@ def choose_vcard_from_list(header: str, vcards: list[Contact], include_none: bool = False - ) -> Optional[Contact]: + ) -> Contact | None: """Let the user select a contact from a list :param header: some text to print in front of the list @@ -269,8 +269,7 @@ return interactive.select(vcards, True) -def get_contact_list(address_books: Union[VdirAddressBook, - AddressBookCollection], +def get_contact_list(address_books: VdirAddressBook | AddressBookCollection, query: Query) -> list[Contact]: """Find contacts in the given address book grouped, sorted and reversed according to the loaded configuration. @@ -731,7 +730,7 @@ if parsable: # We did filter out None above but the type checker does not know # this. - bday = cast(Union[str, datetime.datetime], vcard.birthday) + bday = cast(str | datetime.datetime, vcard.birthday) if isinstance(bday, str): date = bday else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/query.py new/khard-0.21.0/khard/query.py --- old/khard-0.20.1/khard/query.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/khard/query.py 2026-06-15 15:13:21.000000000 +0200 @@ -5,7 +5,7 @@ from functools import reduce from operator import and_, or_ import re -from typing import cast, Any, Optional, Union +from typing import cast, Any from . import contacts @@ -18,11 +18,11 @@ """A query to match against strings, lists of strings and Contacts""" @abc.abstractmethod - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: """Match the self query against the given thing""" @abc.abstractmethod - def get_term(self) -> Optional[str]: + def get_term(self) -> str | None: """Extract the search terms from a query.""" def __and__(self, other: "Query") -> "Query": @@ -70,7 +70,7 @@ """The null-query, it matches nothing.""" - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: return False def get_term(self) -> None: @@ -84,7 +84,7 @@ """The match-anything-query, it always matches.""" - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: return True def get_term(self) -> str: @@ -104,7 +104,7 @@ def __init__(self, term: str) -> None: self._term = term.lower() - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: if isinstance(thing, str): return self._term in thing.lower() return self._term in thing.pretty().lower() @@ -130,14 +130,14 @@ self._field = field super().__init__(value) - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: if isinstance(thing, str): return super().match(thing) if hasattr(thing, self._field): return self._match_union(getattr(thing, self._field)) return False - def _match_union(self, value: Union[str, datetime, list, dict[str, Any]] + def _match_union(self, value: str | datetime | list | dict[str, Any] ) -> bool: if isinstance(value, str): return self.match(value) @@ -172,10 +172,10 @@ def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: return all(q.match(thing) for q in self._queries) - def get_term(self) -> Optional[str]: + def get_term(self) -> str | None: terms = [x.get_term() for x in self._queries] if None in terms: return None @@ -189,7 +189,7 @@ return hash((AndQuery, frozenset(self._queries))) @staticmethod - def reduce(queries: list[Query], start: Optional[Query] = None) -> Query: + def reduce(queries: list[Query], start: Query | None = None) -> Query: return reduce(and_, queries, start or AnyQuery()) def __str__(self) -> str: @@ -203,10 +203,10 @@ def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: return any(q.match(thing) for q in self._queries) - def get_term(self) -> Optional[str]: + def get_term(self) -> str | None: terms = [x.get_term() for x in self._queries] if all(t is None for t in terms): return None @@ -220,7 +220,7 @@ return hash((OrQuery, frozenset(self._queries))) @staticmethod - def reduce(queries: list[Query], start: Optional[Query] = None) -> Query: + def reduce(queries: list[Query], start: Query | None = None) -> Query: return reduce(or_, queries, start or NullQuery()) def __str__(self) -> str: @@ -236,7 +236,7 @@ self._props_query = OrQuery(FieldQuery("formatted_name", term), FieldQuery("nicknames", term)) - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: m = super().match if isinstance(thing, str): return m(thing) @@ -266,13 +266,13 @@ super().__init__(FIELD_PHONE_NUMBERS, value) self._term_only_digits = self._strip_phone_number(value) - def match(self, thing: Union[str, "contacts.Contact"]) -> bool: + def match(self, thing: "str | contacts.Contact") -> bool: if isinstance(thing, str): return self._match_union(thing) else: return super().match(thing) - def _match_union(self, value: Union[str, datetime, list, dict[str, Any]] + def _match_union(self, value: str | datetime | list | dict[str, Any] ) -> bool: if isinstance(value, str): if self._term in value.lower() \ @@ -329,7 +329,7 @@ return 'phone numbers:{}'.format(self._term) -def parse(string: str) -> Union[TermQuery, FieldQuery]: +def parse(string: str) -> TermQuery | FieldQuery: """Parse a string into a query object The input string interpreted as a :py:class:`FieldQuery` if it starts with diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard/version.py new/khard-0.21.0/khard/version.py --- old/khard-0.20.1/khard/version.py 2026-01-01 12:32:14.000000000 +0100 +++ new/khard-0.21.0/khard/version.py 2026-06-15 15:41:59.000000000 +0200 @@ -28,7 +28,7 @@ commit_id: COMMIT_ID __commit_id__: COMMIT_ID -__version__ = version = '0.20.1' -__version_tuple__ = version_tuple = (0, 20, 1) +__version__ = version = '0.21.0' +__version_tuple__ = version_tuple = (0, 21, 0) -__commit_id__ = commit_id = 'g91494f69c' +__commit_id__ = commit_id = 'gfc30a66ca' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard.egg-info/PKG-INFO new/khard-0.21.0/khard.egg-info/PKG-INFO --- old/khard-0.20.1/khard.egg-info/PKG-INFO 2026-01-01 12:32:15.000000000 +0100 +++ new/khard-0.21.0/khard.egg-info/PKG-INFO 2026-06-15 15:41:59.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: khard -Version: 0.20.1 +Version: 0.21.0 Summary: A console address book manager Author-email: Eric Scheibler <[email protected]> License: GPL @@ -17,7 +17,7 @@ Classifier: Intended Audience :: End Users/Desktop Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3 :: Only -Requires-Python: >=3.9 +Requires-Python: >=3.10 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: configobj==5.*,>=5.0.6 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/khard.egg-info/SOURCES.txt new/khard-0.21.0/khard.egg-info/SOURCES.txt --- old/khard-0.20.1/khard.egg-info/SOURCES.txt 2026-01-01 12:32:15.000000000 +0100 +++ new/khard-0.21.0/khard.egg-info/SOURCES.txt 2026-06-15 15:41:59.000000000 +0200 @@ -27,6 +27,7 @@ khard/__main__.py khard/actions.py khard/address_book.py +khard/argparse_helper.py khard/cli.py khard/config.py khard/contacts.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/pyproject.toml new/khard-0.21.0/pyproject.toml --- old/khard-0.20.1/pyproject.toml 2026-01-01 08:53:53.000000000 +0100 +++ new/khard-0.21.0/pyproject.toml 2026-06-15 15:13:21.000000000 +0200 @@ -6,7 +6,7 @@ ] description = "A console address book manager" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", @@ -93,7 +93,7 @@ disallow_untyped_calls = false [tool.pylint.main] -py-version = "3.9" +py-version = "3.10" ignore-paths = ["khard/version.py"] [tool.pylint."messages control"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/test/test_command_line_interface.py new/khard-0.21.0/test/test_command_line_interface.py --- old/khard-0.20.1/test/test_command_line_interface.py 2026-01-01 12:23:30.000000000 +0100 +++ new/khard-0.21.0/test/test_command_line_interface.py 2026-06-15 15:29:15.000000000 +0200 @@ -24,7 +24,7 @@ from khard.helpers.interactive import Editor from khard import khard -from .helpers import TmpConfig, mock_stream +from .helpers import TmpConfig, load_contact, mock_stream def run_main(*args): @@ -61,10 +61,9 @@ class ListingCommands(unittest.TestCase): """Tests for subcommands that simply list stuff.""" - def test_simple_ls_without_options(self): stdout = run_main("list") - text = [l.strip() for l in stdout.getvalue().splitlines()] + text = [line.strip() for line in stdout.getvalue().splitlines()] expected = [ "Address book: foo", "Index Name Phone " @@ -140,8 +139,8 @@ def test_order_of_search_term_does_not_matter(self): stdout1 = run_main('list', 'second', 'contact') stdout2 = run_main('list', 'contact', 'second') - text1 = [l.strip() for l in stdout1.getvalue().splitlines()] - text2 = [l.strip() for l in stdout2.getvalue().splitlines()] + text1 = [line.strip() for line in stdout1.getvalue().splitlines()] + text2 = [line.strip() for line in stdout2.getvalue().splitlines()] expected = [ "Address book: foo", "Index Name Phone " @@ -154,8 +153,8 @@ def test_case_of_search_terms_does_not_matter(self): stdout1 = run_main('list', 'second', 'contact') stdout2 = run_main('list', 'SECOND', 'CONTACT') - text1 = [l.strip() for l in stdout1.getvalue().splitlines()] - text2 = [l.strip() for l in stdout2.getvalue().splitlines()] + text1 = [line.strip() for line in stdout1.getvalue().splitlines()] + text2 = [line.strip() for line in stdout2.getvalue().splitlines()] expected = [ "Address book: foo", "Index Name Phone " @@ -196,7 +195,7 @@ "second contact home [email protected]"] self.assertListEqual(expect, text) - def test_phone_lists_only_contacts_with_phone_nubers(self): + def test_phone_lists_only_contacts_with_phone_numbers(self): with TmpConfig(["contact1.vcf", "contact2.vcf"]): stdout = run_main("phone") text = [line.strip() for line in stdout.getvalue().splitlines()] @@ -388,8 +387,7 @@ self.assertEqual(new, old + 1) -class MiscCommands(unittest.TestCase): - """Tests for other subcommands.""" +class ShowCommands(unittest.TestCase): @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') def test_simple_show_with_yaml_format(self): @@ -404,6 +402,11 @@ self.assertIn('Last name', yaml) self.assertIn('Nickname', yaml) + +class EditCommands(unittest.TestCase): + + # TODO: with pytest this test needs the command line option -s, I don't + # know how to fix that @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') def test_simple_edit_without_modification(self): editor = mock.Mock() @@ -416,6 +419,21 @@ # precisely? editor.edit_templates.assert_called_once() + def test_simple_edit_without_modification_inner1(self): + editor = mock.Mock() + editor.edit_templates = mock.Mock(return_value=None) + editor.write_temp_file = Editor.write_temp_file + with mock.patch('khard.khard.interactive.Editor', + mock.Mock(return_value=editor)): + with mock.patch("sys.stdout"): + khard.modify_subcommand(load_contact("contact1.vcf"), "", True, + False) + # The editor is called with a temp file so how to we check this more + # precisely? + editor.edit_templates.assert_called_once() + + # TODO: with pytest this test needs the command line option -s, I don't + # know how to fix that @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf', EDITOR='editor') def test_edit_source_file_without_modifications(self): @@ -424,25 +442,54 @@ popen.assert_called_once_with(['editor', 'test/fixture/test.abook/contact1.vcf']) + def test_edit_source_file_without_modifications_inner1(self): + with mock.patch('subprocess.Popen') as popen: + khard.modify_subcommand(load_contact("contact1.vcf"), "", True, + True) + popen.assert_called_once_with(['editor', + 'test/fixture/vcards/contact1.vcf']) [email protected]('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') -class CommandLineDefaultsDoNotOverwriteConfigValues(unittest.TestCase): - - @staticmethod - def _with_contact_table(args, **kwargs): - args = cli.parse_args(args) - options = '\n'.join('{}={}'.format(key, kwargs[key]) for key in kwargs) - conf = config.Config(io.StringIO('[addressbooks]\n[[test]]\npath=.\n' - '[contact table]\n' + options)) - return cli.merge_args_into_config(args, conf) + @mock.patch.dict('os.environ', EDITOR='editor') + def test_modify_existing_contact(self): + with mock.patch('subprocess.Popen') as popen: + with mock.patch("sys.stdout"): + khard.modify_existing_contact(load_contact("contact1.vcf")) + popen.assert_called_once() - def test_group_by_addressbook(self): - conf = self._with_contact_table(['list'], group_by_addressbook=True) - self.assertTrue(conf.group_by_addressbook) + def test_modify_existing_contact_2(self): + editor = mock.Mock() + editor.edit_templates = mock.Mock(return_value=None) + editor.write_temp_file = Editor.write_temp_file + with mock.patch('khard.khard.interactive.Editor', + mock.Mock(return_value=editor)): + with mock.patch("sys.stdout"): + khard.modify_existing_contact(load_contact("contact1.vcf")) + # The editor is called with a temp file so how to we check this more + # precisely? + editor.edit_templates.assert_called_once() @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') -class CommandLineArgumentsOverwriteConfigValues(unittest.TestCase): +class MergeCliArgsWithConfig(unittest.TestCase): + + BOOL_SETTINGS = [('general', 'debug'), + ('contact table', 'reverse'), + ('contact table', 'group_by_addressbook'), + ('vcard', 'search_in_source_files'), + ('vcard', 'skip_unparsable')] + + def test_cli_defaults_do_not_override_boolean_config_options(self): + for section, key in self.BOOL_SETTINGS: + with self.subTest(section=section, key=key): + args, _conf = cli.parse_args(["list"]) + conf = config.Config(io.StringIO(f""" + [addressbooks] + [[test]] + path = . + [{section}] + {key} = true""")) + conf = cli.merge_args_into_config(args, conf) + self.assertTrue(getattr(conf, key)) @staticmethod def _merge(args): @@ -472,7 +519,7 @@ self.assertTrue(conf.search_in_source_files) -class Merge(unittest.TestCase): +class MergeSubcommand(unittest.TestCase): def test_merge_with_exact_search_terms(self): with TmpConfig(["contact1.vcf", "contact2.vcf"]): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/test/test_config.py new/khard-0.21.0/test/test_config.py --- old/khard-0.20.1/test/test_config.py 2025-05-03 09:04:23.000000000 +0200 +++ new/khard-0.21.0/test/test_config.py 2026-06-15 15:13:21.000000000 +0200 @@ -2,8 +2,10 @@ # pylint: disable=missing-docstring +import io import logging import os.path +from pathlib import Path import tempfile import unittest import unittest.mock as mock @@ -50,18 +52,63 @@ with self.assertRaises(ConfigError): config.Config(name) + @mock.patch.dict("os.environ", EDITOR="editor", MERGE_EDITOR="meditor") + def test_load_minimal_file_by_name(self): + cfg = config.Config("test/fixture/minimal.conf") + self.assertEqual(cfg.editor, ["editor"]) + self.assertEqual(cfg.merge_editor, "meditor") + + +class DiscoverAddressBooks(unittest.TestCase): def test_discover_books(self): filename = "test/fixture/discover.conf" cfg = config.Config(filename) cfg.init_address_books() - expected = {'broken.abook', 'nick.abook', 'test.abook', 'minimal.abook'} + expected = {"broken.abook", "nick.abook", "test.abook", "minimal.abook"} self.assertEqual(set(cfg.abooks._abooks.keys()), expected) - @mock.patch.dict("os.environ", EDITOR="editor", MERGE_EDITOR="meditor") - def test_load_minimal_file_by_name(self): - cfg = config.Config("test/fixture/minimal.conf") - self.assertEqual(cfg.editor, ["editor"]) - self.assertEqual(cfg.merge_editor, "meditor") + @staticmethod + def config_file(abooks: dict) -> config.Config: + string = "[addressbooks]\n" + for key, value in abooks.items(): + string += f"""[[{key}]] + path = {value["path"]} + type = {value["type"]} + """ + c = config.Config(io.StringIO(string)) + c.init_address_books() + return c + + def test_simple_vdir_and_discover_abooks(self): + c = self.config_file({ + "normal addressbook": {"path": "doc", "type": "vdir"}, + "discover addressbook": {"path": "doc/so*", "type": "discover"}, + }) + self.assertEqual(c.abooks[0].name, "normal addressbook") + self.assertEqual(c.abooks[0].path, "doc") + self.assertEqual(c.abooks[1].name, "source") + self.assertEqual(c.abooks[1].path, "doc/source") + + def test_discover_abooks_ignore_files_when_globbing(self): + c = self.config_file({ + "discover": {"path": "doc/source/*", "type": "discover"}, + }) + self.assertEqual(len(c.abooks), 2) + + def test_names_of_discover_abooks_are_the_basenames_of_the_leaf_directory(self): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + (tmp / "a" / "b" / "c").mkdir(parents=True) + (tmp / "a" / "x" / "x1").mkdir(parents=True) + (tmp / "a" / "x" / "x2").mkdir(parents=True) + (tmp / "a" / "y" / "y1").mkdir(parents=True) + (tmp / "a" / "y" / "y2").mkdir(parents=True) + c = self.config_file({ + "discover": {"path": f"{tmp}/**", "type": "discover"} + }) + actual = sorted([a.name for a in c.abooks]) + expected = ["c", "x1", "x2", "y1", "y2"] + self.assertEqual(actual, expected) class ConfigPreferredVcardVersion(unittest.TestCase): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/test/test_helpers_typing.py new/khard-0.21.0/test/test_helpers_typing.py --- old/khard-0.20.1/test/test_helpers_typing.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/test/test_helpers_typing.py 2026-06-15 15:13:21.000000000 +0200 @@ -149,3 +149,11 @@ string = "1900-01-02T06:42:17-06:00" result = string_to_date(string) self.assertEqual(result, self.zone) + + @unittest.expectedFailure + def test_partial_date_with_february_29(self): + # FIXME: this fails because 1900 is not a leap year, see + # https://github.com/python/cpython/pull/116179 for more info. + string = "--02-29" + result = string_to_date(string) + self.assertEqual(result, datetime.datetime(year=1900, month=2, day=29)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/khard-0.20.1/test/test_vcard_wrapper.py new/khard-0.21.0/test/test_vcard_wrapper.py --- old/khard-0.20.1/test/test_vcard_wrapper.py 2025-01-03 10:45:05.000000000 +0100 +++ new/khard-0.21.0/test/test_vcard_wrapper.py 2026-06-15 15:13:21.000000000 +0200 @@ -4,7 +4,6 @@ import contextlib import datetime import unittest -from typing import Union import vobject @@ -361,7 +360,7 @@ wrapper = TestVCardWrapper() components = ('box', 'extended', 'street', 'code', 'city', 'region', 'country') - expected: dict[str, Union[str, list[str]]] = {item: item + expected: dict[str, str | list[str]] = {item: item for item in components} expected[key] = ["a", "b"] index = components.index(key)
