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)

Reply via email to