diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6eca59a7..cc11ec77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -22,7 +22,7 @@ jobs: python -m pip install flake8 python -m pip install . - name: Run tests - run: python setup.py test + run: python3 -m unittest type-checker: runs-on: ubuntu-latest @@ -35,9 +35,10 @@ jobs: python -m pip install --upgrade pip python -m pip install . python -m pip install mypy - python -m pip install types-atomicwrites + # vobject does fancy dynamic stuff that is hard to type-check + python -m pip install types-atomicwrites # types-vobject - name: Run the type checker - run: mypy --ignore-missing-imports khard + run: mypy docs: runs-on: ubuntu-latest @@ -53,87 +54,3 @@ jobs: run: | python setup.py build make -C doc html man - - -# -# -##-------------------------------------- python package -# -## This workflow will install Python dependencies, run tests and lint with a variety of Python versions -## For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -# Python-package-build: -# -# runs-on: ubuntu-latest -# strategy: -# fail-fast: false -# matrix: -# python-version: ["3.8", "3.9", "3.10"] -# -# steps: -# - uses: actions/checkout@v2 -# - name: Set up Python ${{ matrix.python-version }} -# uses: actions/setup-python@v2 -# with: -# python-version: ${{ matrix.python-version }} -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip -# python -m pip install flake8 -# python -m pip install . -# - name: Lint with flake8 -# run: | -# # stop the build if there are Python syntax errors or undefined names -# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics -# # exit-zero treats all errors as warnings. -# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics -# - name: Run tests -# run: python setup.py test -# -##-------------------------------------- python app -# -## This workflow will install Python dependencies, run tests and lint with a single version of Python -## For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -# Python-application-build: -# -# runs-on: ubuntu-latest -# -# steps: -# - uses: actions/checkout@v2 -# - name: Set up Python 3.10 -# uses: actions/setup-python@v2 -# with: -# python-version: "3.10" -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip -# pip install flake8 pytest -# if [ -f requirements.txt ]; then pip install -r requirements.txt; fi -# - name: Lint with flake8 -# run: | -# # stop the build if there are Python syntax errors or undefined names -# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics -# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide -# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics -# - name: Test with pytest -# run: | -# pytest -# -##-------------------------------------- python lint -# Pylint-build: -# runs-on: ubuntu-latest -# strategy: -# matrix: -# python-version: ["3.8", "3.9", "3.10"] -# steps: -# - uses: actions/checkout@v2 -# - name: Set up Python ${{ matrix.python-version }} -# uses: actions/setup-python@v2 -# with: -# python-version: ${{ matrix.python-version }} -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip -# pip install pylint -# - name: Analysing the code with pylint -# run: | -# pylint $(git ls-files '*.py') diff --git a/CHANGES b/CHANGES index be1307c8..043b5b1d 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,21 @@ Change Log ========== +v0.19.0: 2023-11-23 + +- Remove support for python 3.7 +- Run tests on python 3.11 and 3.12 in CI +- Fix yaml conversion of multiple addresses with same label (#323, #324) +- Improve error message for address book paths (884f1d9) +- Add pyproject.toml file and deprecate setup.py +- Remove deprecated options from --help and zsh completion +- Internal changes: + - Add a general ask() function (#320, #326) + - Use an exception to cancle user interactions (#325) + - More and stricter type hints + - Promote AddressBookCollection to a Sequence + + v0.18.0: 2022-12-10 - Move project home on GitHub from @scheibler to @lucc - Catch exceptions when loading the config (#294) @@ -53,7 +68,7 @@ v0.15.0: 2019-10-24 - handle ABLABELs on most fields - add formatted name to the yaml template - show formatted name in contact details -- make it possible to sort by and display formatted name in linstings +- make it possible to sort by and display formatted name in listings - remove the khard-runner.py helper script - validate the config file upon loading it - internal code refactoring @@ -64,7 +79,7 @@ v0.14.0: 2019-06-21 - Allow vcard selections to be aborted explicitly - Unify edit and source subcommands - Merge export and show subcommands -- Turn template export into a seperate command +- Turn template export into a separate command - Require python >= 3.5 - Add html documentation (generated with sphinx) - Add man page (generated with sphinx) @@ -138,7 +153,7 @@ v0.11.0: 2016-07-17 --skip-unparsable to skip unparsable vcard files (#75) --strict-search to narrow the contact search to the name field - Added some aliases for program actions (#65) -- Removed davcontroler module due to the python3 incompatibility (script moved into the misc folder) +- Removed davcontroller module due to the python3 incompatibility (script moved into the misc folder) - Updated zsh completion function and khards example config file @@ -183,11 +198,11 @@ v0.7.3: 2016-01-08 v0.7.2: 2016-01-03 -- Use of module atomicwrites to securely write vcards to disk +- Use of module atomicwrites to securely write vCards to disk v0.7.1: 2016-01-01 -- Added support for multiple instances of one vcard attribute +- Added support for multiple instances of one vCard attribute v0.7.0: 2015-12-18 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9fe70eb7..71f02da2 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -23,7 +23,7 @@ easier for maintainers to help: - report the version(s) that are affected - state the python version you are using - if there are stack tracebacks post them with your bug report -- supply a minimal configuration (config file and vcards) to reproduce the +- supply a minimal configuration (config file and vCards) to reproduce the error Feature requests @@ -47,21 +47,31 @@ Please stick to the following standards when you open pull requests: Development ----------- -In order to start coding you need to fetch the develop branch: +In order to start coding you need to fetch the ``develop`` branch: .. code-block:: shell git clone https://github.com/lucc/khard cd khard - python setup.py build # to generate the version.py file - python -m khard --help - # or + +It is recommended to create a `virtualenv`_ to isolate the development +environment for Khard from your system's Python installation: + +.. code-block:: shell + + python3 -m venv khard-dev-venv + . khard-dev-venv/bin/activate + +The you can install the dependencies with ``pip``: + +.. code-block:: shell + pip3 install --editable . khard --help -Alternatively you can use the ``setup.py`` script directly. If you want to -isolate khard from your system Python environment you can use a `virtualenv`_ -to do so. +If you have the `Nix`_ package manager installed you can use the ``flake.nix`` +that is provided with Khard. It provides an isolated Python version with all +dependencies with ``nix develop``. .. _bug reports: https://github.com/lucc/khard/issues .. _the Git book: https://www.git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines @@ -69,6 +79,7 @@ to do so. .. _feature requests: https://github.com/lucc/khard/pulls .. _Github: https://github.com/lucc/khard .. _master: https://github.com/lucc/khard/tree/master +.. _Nix: https://nixos.org .. _PEP 8: https://www.python.org/dev/peps/pep-0008/ .. _pylint: https://pylint.readthedocs.io/en/latest/ .. |travis| image:: https://github.com/lucc/khard/actions/workflows/ci.yml/badge.svg diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index c22f8e96..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -# https://packaging.python.org/guides/using-manifest-in/ -include AUTHORS -include CHANGES -recursive-include misc * diff --git a/README.md b/README.md index 8676d9b0..03e3da08 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ can find more information about khard and the whole synchronization process [here][blog]. Warning: If you want to create or modify contacts with khard, beware that the -vcard standard is very inconsistent and lacks interoperability. Different +vCard standard is very inconsistent and lacks interoperability. Different actors in that sector have defined their own extensions and even produce non-standard output. A good example is the type value, which is tied to phone numbers, email and post addresses. Khard tries to avoid such incompatibilities but if you sync your contacts with an Android or iOS device, expect problems. You are on the safe side, if you only use khard to read contacts. For further -information about the vcard compatibility issues have a look into [this blog +information about the vCard compatibility issues have a look into [this blog post][sad]. Installation diff --git a/doc/source/commandline.rst b/doc/source/commandline.rst index 0255632c..860f7cd9 100644 --- a/doc/source/commandline.rst +++ b/doc/source/commandline.rst @@ -140,8 +140,8 @@ Assuming the user had configured the three supported private object "Jabber", "Skype", and "Twitter" in their config, the template would look :download:`like this `. -Per default khard creates vcards of version 3.0. If your other contact -applications support vcards of the more recent version 4.0, you may change this +Per default khard creates vCards of version 3.0. If your other contact +applications support vCards of the more recent version 4.0, you may change this with the option :option:`--vcard-version`. Example: .. code-block:: shell @@ -149,7 +149,7 @@ with the option :option:`--vcard-version`. Example: khard new --vcard-version=4.0 For a more permanent solution you may set the preferred_version parameter in -the vcard section of the khard config file (see the :download:`example config +the vCard section of the khard config file (see the :download:`example config file ` for more details). But beware, that khard cannot convert already existing contacts from version 3.0 to 4.0. Therefore this setting is not applicable to the modify action. diff --git a/doc/source/man.rst b/doc/source/man.rst index e6f151e8..ac9eabe2 100644 --- a/doc/source/man.rst +++ b/doc/source/man.rst @@ -1,5 +1,5 @@ -Manpages -======== +Man pages +========= The following man pages are available for khard: diff --git a/doc/source/man/khard.conf.rst b/doc/source/man/khard.conf.rst index 1835e5e5..2f6cb74f 100644 --- a/doc/source/man/khard.conf.rst +++ b/doc/source/man/khard.conf.rst @@ -41,9 +41,9 @@ 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. The *path* value + the folder containing the vCard files for that addressbook. The *path* value supports environment variables and tilde prefixes. :program:`khard` expects - the vcard files to hold only one VCARD record each and end in a :file:`.vcf` + the vCard files to hold only one VCARD record each and end in a :file:`.vcf` extension. general @@ -80,11 +80,11 @@ contact table vcard - *private_objects*: a list of strings, these are the names of private vCard fields (starting with ``X-``) that will be loaded and displayed by khard - - *search_in_source_files*: whether to search in the vcard files before + - *search_in_source_files*: whether to search in the vCard files before parsing them in order to speed up searches - - *skip_unparsable*: whether to skip unparsable vcards, otherwise khard exits - on the first unparsable card it encounters - - *preferred_version*: the preferred vcard version to use for new cards + - *skip_unparsable*: whether to skip unparsable vCards, otherwise khard + exits on the first unparsable card it encounters + - *preferred_version*: the preferred vCard version to use for new cards Example ------- diff --git a/doc/source/man/khard.rst b/doc/source/man/khard.rst index bc8e1ed2..18876bcd 100644 --- a/doc/source/man/khard.rst +++ b/doc/source/man/khard.rst @@ -121,7 +121,7 @@ Spaces in the field name have to be replaced with underscores. The available fields are the same fields as in the YAML template with the exception of the five name components (first, last, prefix, suffix, additional). But there is the special pseudo field specifier ``name:`` which -will search in *any* name related field (including nichnames and formatted +will search in *any* name related field (including nicknames and formatted names). If a field name is not known the search term is interpreted as a plain search diff --git a/doc/source/scripting.rst b/doc/source/scripting.rst index 6fcb79fb..7dc172d1 100644 --- a/doc/source/scripting.rst +++ b/doc/source/scripting.rst @@ -53,6 +53,22 @@ extension. .. _vdirsyncer: https://github.com/pimutils/vdirsyncer/ +If you already have ``.vcf`` files containing multiple ``VCARD`` entries (i.e. +from Android/MacOS Contacts app), below are some scripts that +generate the corresponding single entry ``.vcf`` files: + +* `vcardtool`_ (processes one input file at a time) +* `vcf-splitter`_ (needs to be used with the ``-u``/``--uid`` flag to generate + the required UID entry) + +.. _vcardtool: https://github.com/jakeogh/vcardtool/ +.. _vcf-splitter: https://framagit.org/rogarb/vcf-splitter/ + +You might need to preparse your ``.vcf`` input files with `vcard2to3`_ if they +contain ``VERSION:2.1`` entries. + +.. _vcard2to3: https://github.com/jowave/vcard2to3 + vdirsyncer ---------- diff --git a/flake.lock b/flake.lock index b38c69a4..b00b5f75 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1670058159, - "narHash": "sha256-ERiP2JWanLuGV1PDyHTbcigFCfIi9oco5LFdMJHjREE=", + "lastModified": 1696009558, + "narHash": "sha256-/1nNL8lCF0gn38XaFyu2ufpWcBFwCDZyYUxdZkM6GxU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "49b8ad618e64d9fe9ab686817bfebe047860dcae", + "rev": "c182df2e68bd97deb32c7e4765adfbbbcaf75b60", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 55a457c5..f37ddd38 100644 --- a/flake.nix +++ b/flake.nix @@ -4,13 +4,41 @@ outputs = { self, nixpkgs }: { packages.x86_64-linux.default = - nixpkgs.legacyPackages.x86_64-linux.khard.overrideAttrs (oa: rec { - pname = "khard"; + nixpkgs.legacyPackages.x86_64-linux.khard.overridePythonAttrs (oa: rec { name = "khard-${version}"; - version = "dev-${if self ? shortRev then self.shortRev else "dirty"}"; + version = "${oa.version}post-dev+${if self ? shortRev then self.shortRev else "dirty"}"; SETUPTOOLS_SCM_PRETEND_VERSION = version; + postInstall = '' + ${oa.postInstall} + cp -r $src/khard/data $out/lib/python*/site-packages/khard + ''; src = ./.; }); - + devShells.x86_64-linux.release = + let pkgs = nixpkgs.legacyPackages.x86_64-linux; in + pkgs.mkShell { + packages = with pkgs; [ + git + twine + (python3.withPackages (p: with p; [ + build + mypy + pylint + setuptools + setuptools-scm + wheel + ] ++ self.packages.x86_64-linux.default.propagatedBuildInputs)) + ]; + shellHook = '' + cat < Optional[str]: """Find the name of the action for the supplied alias. If no action is - asociated with the given alias, None is returned. + associated with the given alias, None is returned. :param alias: the alias to look up :returns: the name of the corresponding action or None diff --git a/khard/address_book.py b/khard/address_book.py index e3ff208b..b5afcf6f 100644 --- a/khard/address_book.py +++ b/khard/address_book.py @@ -2,10 +2,11 @@ import abc import binascii +from collections.abc import Mapping, Sequence import glob import logging import os -from typing import Dict, Generator, Iterator, List, Optional, Union +from typing import Dict, Generator, Iterator, List, Optional, Union, overload import vobject.base @@ -62,7 +63,7 @@ def _compare_uids(uid1: str, uid2: str) -> int: :param uid1: first uid to compare :param uid2: second uid to compare - :returns: the length of the shortes unequal initial substrings + :returns: the length of the shortest unequal initial substrings """ return len(os.path.commonprefix((uid1, uid2))) @@ -70,7 +71,7 @@ def search(self, query: Query) -> Generator["carddav_object.CarddavObject", None, None]: """Search this address book for contacts matching the query. - The backend for this address book migth be load()ed if needed. + The backend for this address book might be load()ed if needed. :param query: the query to search for :yields: all found contacts @@ -84,13 +85,14 @@ def search(self, query: Query) -> Generator["carddav_object.CarddavObject", def get_short_uid_dict(self, query: Query = AnyQuery()) -> Dict[ str, "carddav_object.CarddavObject"]: - """Create a dictionary of shortend UIDs for all contacts. + """Create a dictionary of shortened UIDs for all contacts. All arguments are only used if the address book is not yet initialized and will just be handed to self.load(). :param query: see self.load() - :returns: the contacts mapped by the shortes unique prefix of their UID + :returns: the contacts mapped by the shortest unique prefix of their + UID """ if self._short_uids is None: if not self._loaded: @@ -104,12 +106,12 @@ def get_short_uid_dict(self, query: Query = AnyQuery()) -> Dict[ self._short_uids = {} sorted_uids = sorted(self.contacts) # Prepare for the loop; the first and last items are handled - # seperatly. + # separately. item0, item1 = sorted_uids[:2] same1 = self._compare_uids(item0, item1) self._short_uids[item0[:same1 + 1]] = self.contacts[item0] for item_new in sorted_uids[2:]: - # shift the items and the common prefix lenght one further + # shift the items and the common prefix length one further item0, item1 = item1, item_new same0, same1 = same1, self._compare_uids(item0, item1) # compute the final prefix length for item1 @@ -120,10 +122,10 @@ def get_short_uid_dict(self, query: Query = AnyQuery()) -> Dict[ return self._short_uids def get_short_uid(self, uid: str) -> str: - """Get the shortend UID for the given UID. + """Get the shortened UID for the given UID. :param uid: the full UID to shorten - :returns: the shortend uid or the empty string + :returns: the shortened uid or the empty string """ if uid: short_uids = self.get_short_uid_dict() @@ -148,7 +150,7 @@ class VdirAddressBook(AddressBook): """An AddressBook implementation based on a vdir. This address book can load contacts from vcard files that reside in one - direcotry on disk. + directory on disk. """ def __init__(self, name: str, path: str, @@ -164,8 +166,8 @@ def __init__(self, name: str, path: str, """ self.path = os.path.expanduser(os.path.expandvars(path)) if not os.path.isdir(self.path): - raise FileNotFoundError("[Errno 2] The path {} to the address book" - " {} does not exist.".format(path, name)) + raise NotADirectoryError("The path {} to the address book {} is " + "not a directory".format(path, name)) self._private_objects = private_objects or [] self._localize_dates = localize_dates self._skip = skip @@ -225,10 +227,10 @@ def load(self, query: Query = AnyQuery(), len(self.contacts), self.name) -class AddressBookCollection(AddressBook): +class AddressBookCollection(AddressBook, Mapping, Sequence): """A collection of several address books. - This represents a temporary merege of the contact collections provided by + This represents a temporary merge of the contact collections provided by the underlying address books. On load, all contacts from all subaddressbooks are copied into a dict in this address book. This allows this class to use all other methods from the parent AddressBook class. @@ -267,11 +269,16 @@ def load(self, query: Query = AnyQuery()) -> None: logger.debug('Loaded %s contacts from address book %s.', len(self.contacts), self.name) - def __getitem__(self, key: Union[int, str]) -> VdirAddressBook: - """Get one of the backing address books by name or index + @overload + def __getitem__(self, key: Union[int, str]) -> VdirAddressBook: ... + @overload + def __getitem__(self, key: slice) -> List[VdirAddressBook]: ... + def __getitem__(self, key: Union[int, str, slice] + ) -> Union[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 - :returns: the matching address book + :returns: the matching address book(s) :throws: KeyError """ if isinstance(key, str): diff --git a/khard/carddav_object.py b/khard/carddav_object.py index 63ae0343..a255d9dc 100644 --- a/khard/carddav_object.py +++ b/khard/carddav_object.py @@ -15,7 +15,8 @@ import re import sys import time -from typing import Callable, Dict, List, Optional, Tuple, Union, Sequence +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, \ + TypeVar, Union, Sequence, overload from atomicwrites import atomic_write from ruamel import yaml @@ -24,28 +25,33 @@ from . import address_book # pylint: disable=unused-import # for type checking from . import helpers -from .helpers.typing import (convert_to_vcard, Date, ObjectType, StrList, - list_to_string, string_to_date, string_to_list) +from .helpers.typing import (Date, ObjectType, PostAddress, StrList, + convert_to_vcard, list_to_string, string_to_date, string_to_list) from .query import AnyQuery, Query logger = logging.getLogger(__name__) +T = TypeVar("T") -def multi_property_key(item: Union[str, Dict]) -> List: - """key function to pass to sorted(), allowing sorting of dicts with lists +@overload +def multi_property_key(item: str) -> Tuple[Literal[0], str]: ... +@overload +def multi_property_key(item: Dict[T, Any]) -> Tuple[Literal[1], T]: ... +def multi_property_key(item: Union[str, Dict[T, Any]] + ) -> Tuple[int, Union[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. :param item: member of the list being sorted :type item: a dict with a single entry or any sortable type - :returns: a list with two members. The first is int(isinstance(item, dict). + :returns: a pair, the first item is int(isinstance(item, dict). The second is either the key from the dict or the unchanged item if it is not a dict. - :rtype: list(int, type(item)) or list(int, str) """ if isinstance(item, dict): - return [1, list(item)[0]] - return [0, item] + return (1, next(iter(item))) + return (0, item) class VCardWrapper: @@ -108,14 +114,14 @@ def _get_multi_property(self, name: str) -> List: """Get a vCard property that can exist more than once. It does not matter what the individual vcard properties store as their - value. This function returnes them untouched inside an agregating + value. This function returns them untouched inside an aggregating list. If the property is part of a group containing exactly two items, with exactly one ABLABEL. the property will be prefixed with that ABLABEL. :param name: the name of the property (should be UPPER case) - :returns: the values from all occurences of the named property + :returns: the values from all occurrences of the named property """ values = [] for child in self.vcard.getChildren(): @@ -343,8 +349,8 @@ def anniversary(self, date: Date) -> None: def _get_ablabel(self, item: vobject.base.ContentLine) -> str: """Get an ABLABEL for a specified item in the vCard. - Will return the ABLABEL only if the item is part of a group with exactly - two items, exactly one of which is an ABLABEL. + Will return the ABLABEL only if the item is part of a group with + exactly two items, exactly one of which is an ABLABEL. :param item: the item to be labelled :returns: the ABLABEL in the circumstances above or an empty string @@ -365,8 +371,8 @@ def _get_ablabel(self, item: vobject.base.ContentLine) -> str: return label def _get_new_group(self, group_type: str = "") -> str: - """Get an unused group name for adding new groups. Uses the form item123 - or itemgroup_type123 if a grouptype is specified. + """Get an unused group name for adding new groups. Uses the form + item123 or itemgroup_type123 if a grouptype is specified. :param group_type: (Optional) a string to add between "item" and the number @@ -770,11 +776,11 @@ def add_email(self, type: str, address: str) -> None: label_obj.value = custom_types[0] @property - def post_addresses(self) -> Dict[str, List[Dict[str, Union[List, str]]]]: + def post_addresses(self) -> Dict[str, List[PostAddress]]: """ :returns: dict of type and post address list """ - post_adr_dict: Dict[str, List[Dict[str, Union[List, str]]]] = {} + post_adr_dict: Dict[str, List[PostAddress]] = {} for child in self.vcard.getChildren(): if child.name == "ADR": type = list_to_string(self._get_types_for_vcard_object( @@ -878,20 +884,20 @@ def _add_post_address(self, type, box, extended, street, code, city, class YAMLEditable(VCardWrapper): - """Conversion of vcards to YAML and updateing the vcard from YAML""" + """Conversion of vcards to YAML and updating the vcard from YAML""" def __init__(self, vcard: vobject.vCard, supported_private_objects: Optional[List[str]] = None, version: Optional[str] = None, localize_dates: bool = False ) -> None: - """Initialize atributes needed for yaml conversions + """Initialize attributes needed for yaml conversions :param supported_private_objects: the list of private property names that will be loaded from the actual vcard and represented in this pobject :param version: the version of the RFC to use in this card :param localize_dates: should the formatted output of anniversary - and birthday be localized or should the isoformat be used instead + and birthday be localized or should the iso format be used instead """ self.supported_private_objects = supported_private_objects or [] self.localize_dates = localize_dates @@ -965,11 +971,11 @@ def _filter_invalid_tags(contents: str) -> str: @staticmethod def _parse_yaml(input: str) -> Dict: - """Parse a YAML document into a dictinary and validate the data to some - degree. + """Parse a YAML document into a dictionary and validate the data to + some degree. :param str input: the YAML document to parse - :returns: the parsed datastructure + :returns: the parsed data structure :rtype: dict """ yaml_parser = YAML(typ='base') @@ -994,7 +1000,8 @@ def _parse_yaml(input: str) -> Dict: @staticmethod def _set_string_list(setter: Callable[[Union[str, List]], None], key: str, data: Dict) -> None: - """Prepocess a string or list and set each value with the given setter + """Pre-process a string or list and set each value with the given + setter :param setter: the setter method to add a value to a card :param key: @@ -1054,7 +1061,7 @@ def update(self, input: str) -> None: # name self._delete_vcard_object("N") - # although the "n" attribute is not explisitely required by the vcard + # although the "n" attribute is not explicitly required by the vcard # specification, # the vobject library throws an exception, if it doesn't exist # so add the name regardless if it's empty or not @@ -1308,10 +1315,10 @@ def __init__(self, vcard: vobject.vCard, :param filename: the path to the file where this vcard is stored :param supported_private_objects: the list of private property names that will be loaded from the actual vcard and represented in this - pobject + object :param vcard_version: the version of the RFC to use :param localize_dates: should the formatted output of anniversary and - birthday be localized or should the isoformat be used instead + birthday be localized or should the iso format be used instead """ self.address_book = address_book self.filename = filename @@ -1350,9 +1357,9 @@ def from_file(cls, address_book: "address_book.VdirAddressBook", the file unconditionally :param supported_private_objects: the list of private property names that will be loaded from the actual vcard and represented in this - pobject + object :param localize_dates: should the formatted output of anniversary - and birthday be localized or should the isoformat be used instead + and birthday be localized or should the iso format be used instead :returns: the loaded CarddavObject or None if the file didn't match """ with open(filename, "r") as file: diff --git a/khard/cli.py b/khard/cli.py index e8dcc4ca..81805e11 100644 --- a/khard/cli.py +++ b/khard/cli.py @@ -18,16 +18,16 @@ class FieldsArgument: """A factory to create callable objects for add_argument's type= parameter. - The object can parse comma seperated strings into list of strings, and can + The object can parse comma separated strings into list of strings, and can also check if the single elements are spelled correctly. """ def __init__(self, *choices: str, nested: bool = False) -> None: """Initialize the factory - :param choices: the comma seperated strings must be one of these - :param nested: if this is true the comma seperated strings may - designate nested fields and only the first component (seperated by + :param choices: the comma separated strings must be one of these + :param nested: if this is true the comma separated strings may + designate nested fields and only the first component (separated by a dot) must match on of the choices """ self._choices = sorted(choices) @@ -52,7 +52,7 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, argparse.ArgumentParser]: """Create two argument parsers. - The first parser is manly used to find the config file which can than be + The first parser is mainly used to find the config file which can than be used to set some default values on the second parser. The second parser can parse the remainder of the command line with the subcommand and all further options and arguments. @@ -155,12 +155,13 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, help="Look into source vcf files to speed up search queries in " "large address books. Beware that this option could lead " "to incomplete results.") + # TODO remove after version 0.19 default_search_parser.add_argument( - "-e", "--strict-search", action="store_true", - help="DEPRECATED use the new query syntax instead") + "-e", "--strict-search", action="store_true", help=argparse.SUPPRESS) + # TODO remove after version 0.19 default_search_parser.add_argument( "-u", "--uid", type=lambda x: FieldQuery("uid", x), - help="DEPRECATED use the new query syntax instead") + help=argparse.SUPPRESS) default_search_parser.add_argument( "search_terms", nargs="*", metavar="search terms", type=parse, default=[], help="search in specified or all fields to find matching " @@ -171,18 +172,20 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, help="Look into source vcf files to speed up search queries in " "large address books. Beware that this option could lead " "to incomplete results.") + # TODO remove after version 0.19 merge_search_parser.add_argument( - "-e", "--strict-search", action="store_true", - help="DEPRECATED use the new query syntax instead") + "-e", "--strict-search", action="store_true", help=argparse.SUPPRESS) merge_search_parser.add_argument( "-t", "--target-contact", "--target", type=parse, help="search for a matching target contact") + # TODO remove after version 0.19 merge_search_parser.add_argument( "-u", "--uid", type=lambda x: FieldQuery("uid", x), - help="DEPRECATED use the new query syntax instead") + help=argparse.SUPPRESS) + # TODO remove after version 0.19 merge_search_parser.add_argument( "-U", "--target-uid", type=lambda x: FieldQuery("uid", x), - help="DEPRECATED use -t with the new query syntax instead") + help=argparse.SUPPRESS) merge_search_parser.add_argument( "source_search_terms", nargs="*", metavar="source", type=parse, default=[], @@ -372,13 +375,13 @@ def parse_args(argv: List[str]) -> Tuple[argparse.Namespace, Config]: :returns: the namespace parsed from the command line """ first_parser, parser = create_parsers() - # Parese the command line with the first argument parser. It will handle + # Parse the command line with the first argument parser. It will handle # the config option (its main job) and also the help, version and debug # options as these do not depend on anything else. args = first_parser.parse_args(argv) remainder = args.remainder - # Set the loglevel to debug if given on the command line. This is done + # Set the log level to debug if given on the command line. This is done # before parsing the config file to make it possible to debug the parsing # of the config file. if "debug" in args and args.debug: @@ -457,9 +460,16 @@ def parse_args(argv: List[str]) -> Tuple[argparse.Namespace, Config]: or AnyQuery() # Remove uid values from the args Namespace. They have been merged into # the search terms above. + # TODO remove after version 0.19 if "uid" in args: + if args.uid: + logger.error("Deprecated option --uid, use the new query syntax " + "instead.") del args.uid if "target_uid" in args: + if args.target_uid: + logger.error("Deprecated option --target-uid, use the new query " + "syntax instead.") del args.target_uid return args, config @@ -473,7 +483,7 @@ def merge_args_into_config(args: argparse.Namespace, config: Config) -> Config: :returns: the merged config object """ config.merge_args(args) - # Now we can savely initialize the address books as all command line + # Now we can safely initialize the address books as all command line # options have been incorporated into the config object. config.init_address_books() # If the user could but did not specify address books on the command line diff --git a/khard/config.py b/khard/config.py index 4af36d9f..b90fa1f7 100644 --- a/khard/config.py +++ b/khard/config.py @@ -1,6 +1,7 @@ """Loading and validation of the configuration file""" from argparse import Namespace +import io import locale import logging import os @@ -22,6 +23,10 @@ logger = logging.getLogger(__name__) +# 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] class ConfigError(Exception): @@ -88,7 +93,7 @@ class Config: supported_vcard_versions = ("3.0", "4.0") - def __init__(self, config_file: Optional[str] = None) -> None: + def __init__(self, config_file: Optional[ConfigFile] = None) -> None: self.config: configobj.ConfigObj self.abooks: AddressBookCollection locale.setlocale(locale.LC_ALL, '') @@ -97,7 +102,7 @@ def __init__(self, config_file: Optional[str] = None) -> None: self._set_attributes() @classmethod - def _load_config_file(cls, config_file: Optional[str] + def _load_config_file(cls, config_file: Optional[ConfigFile] ) -> configobj.ConfigObj: """Find and load the config file. @@ -220,7 +225,7 @@ def merge(self, other: Union[configobj.ConfigObj, Dict]) -> None: def merge_args(self, args: Namespace) -> None: """Merge options from a flat argparse object. - :param argparse.Namespace args: the parsed arguments to incorperate + :param argparse.Namespace args: the parsed arguments to incorporate """ skel = {'general': ['debug'], 'contact table': ['reverse', 'group_by_addressbook', diff --git a/khard/formatter.py b/khard/formatter.py index 06c64f7c..9ed4f57f 100644 --- a/khard/formatter.py +++ b/khard/formatter.py @@ -7,9 +7,9 @@ class Formatter: - """A formtter for CarddavObject. + """A formatter for CarddavObject. - It recieves some settings on initialisation which influence the formatting + It receives some settings on initialisation which influence the formatting of the contact. """ @@ -29,7 +29,7 @@ def __init__(self, display: str, preferred_email: List[str], @staticmethod def format_labeled_field(field: Dict[str, List[str]], preferred: List[str] ) -> str: - """Format a labeled field from a vcard for display, the first entry + """Format a labeled field from a vCard for display, the first entry under the preferred label will be returned :param field: the labeled field, this must not be empty! diff --git a/khard/helpers/__init__.py b/khard/helpers/__init__.py index e8a50f8a..e549730f 100644 --- a/khard/helpers/__init__.py +++ b/khard/helpers/__init__.py @@ -7,7 +7,10 @@ from typing import Any, Dict, List, Optional, Sequence, Union from ruamel.yaml.scalarstring import LiteralScalarString -from .typing import list_to_string +from .typing import list_to_string, PostAddress + + +YamlPostAddresses = Dict[str, Union[List[Dict[str, Any]], Dict[str, Any]]] def pretty_print(table: List[List[str]], justify: str = "L") -> str: @@ -62,9 +65,10 @@ def get_random_uid() -> str: return ''.join([random.choice(string.ascii_lowercase + string.digits) for _ in range(36)]) + def yaml_clean(value: Union[str, Sequence, Dict[str, Any], None] - ) -> Union[Sequence, str, Dict[str, Any], LiteralScalarString, - None]: + ) -> Union[Sequence, str, Dict[str, Any], LiteralScalarString, + None]: """ sanitize yaml values according to some simple principles: 1. empty values are none, so ruamel does not print an empty list/str @@ -114,10 +118,10 @@ def yaml_dicts( return data_dict -def yaml_addresses(addresses: Optional[Dict[str, Any]], +def yaml_addresses(addresses: Optional[Dict[str, List[PostAddress]]], address_properties: List[str], defaults: Optional[List[str]] = None - ) -> Optional[Dict[str, Any]]: + ) -> Optional[YamlPostAddresses]: """ build a dict from an address, using a list of properties, an address has. @@ -133,24 +137,27 @@ def yaml_addresses(addresses: Optional[Dict[str, Any]], address_fields = {key: None for key in address_properties} return {address_type: address_fields for address_type in defaults} - address_dict = {} - for address_type, address in addresses.items(): - if isinstance(address, list): - address = address[0] - address_dict[address_type] = { - key: yaml_clean(address.get(f"{key[0].lower()}{key[1:]}")) - for key in address_properties - } + address_dict: YamlPostAddresses = {} + for address_type, addresses_ in addresses.items(): + entry = [ + {key: yaml_clean(address.get(f"{key[0].lower()}{key[1:]}")) + for key in address_properties} + for address in addresses_ + ] + if len(entry) == 1: + address_dict[address_type] = entry[0] + else: + address_dict[address_type] = entry return address_dict def yaml_anniversary(anniversary: Union[str, datetime, None], version: str) -> Optional[str]: """ - format an anniversary according to its contents and the VCard version. + format an anniversary according to its contents and the vCard version. :param anniversary: a string or a datetime object, that is the anniversary - :param version: the VCard version to format for + :param version: the vCard version to format for :returns: the formatted date string """ if not anniversary: diff --git a/khard/helpers/interactive.py b/khard/helpers/interactive.py index ff372efc..79db4617 100644 --- a/khard/helpers/interactive.py +++ b/khard/helpers/interactive.py @@ -6,7 +6,8 @@ import os.path import subprocess from tempfile import NamedTemporaryFile -from typing import Callable, Generator, List, Optional, TypeVar, Union +from typing import Callable, Generator, List, Optional, Sequence, \ + TypeVar, Union from ..carddav_object import CarddavObject @@ -14,6 +15,12 @@ T = TypeVar("T") +class Canceled(Exception): + """An exception indicating that the user canceled some operation.""" + def __init__(self, message: str = "Canceled") -> None: + super().__init__(message) + + def confirm(message: str, accept_enter_key: bool = True) -> bool: """Ask the user for confirmation on the terminal. @@ -21,19 +28,59 @@ def confirm(message: str, accept_enter_key: bool = True) -> bool: :param accept_enter_key: Accept ENTER as alternative for "n" :returns: the answer of the user """ + return "yes" == ask(message, ["yes", "no"], + "no" if accept_enter_key else None) + + +def ask(message: str, choices: List[str], default: Optional[str] = None, + help: Optional[str] = None) -> str: + """Ask the user to select one of the given choices + + :param message: a text to show to the user + :param choices: the possible answers the user might give, if help is not + None this list must not contain the string "?" + :param default: the answer that should be selected on empty user input + (None means empty input is not accepted) + :parm help: a help text to display to the user if they did not answer + correctly + :returns: the choice of the user + """ + default = default.lower() if default is not None else None + # ensure that the choices are lower case, in order but unique + choices = list({c.lower(): None for c in choices}) + prompt = "/".join("[{}]".format(c) if c == default else c + for c in choices) + if help is not None: + prompt += " or ? for help" + prompt += ": " + if len(message) + len(prompt) < 79: + prompt = message + " " + prompt + else: + print(message) while True: - answer = input(message + ' (y/N) ') - answer = answer.lower() - if answer == 'y': - return True - if answer == 'n': - return False - if answer == '' and accept_enter_key: - return False - print('Please answer with "y" for yes or "n" for no.') - - -def select(items: List[T], include_none: bool = False) -> Optional[T]: + try: + answer = input(prompt).lower() + if answer == "" and default is not None: + return default + if answer == "?" and help is not None: + print(help) + continue + if answer in choices: + return answer + prefixes_matches = [c for c in choices if c.startswith(answer)] + if len(prefixes_matches) == 1: + return prefixes_matches[0] + if len(prefixes_matches) > 1: + print("The given prefix is not specific enough.") + except (EOFError, IndexError, ValueError): + pass + except KeyboardInterrupt: + raise Canceled + if help is not None: + print(help) + + +def select(items: Sequence[T], include_none: bool = False) -> Optional[T]: """Ask the user to select an item from a list. The list should be displayed to the user before calling this function and @@ -42,15 +89,16 @@ def select(items: List[T], include_none: bool = False) -> Optional[T]: :param items: the list from which to select :param include_none: whether to allow the selection of no item :returns: None or the selected item + :raises Canceled: when the user canceled the selection process """ + prompt = "Enter Index ({}q to quit): ".format("0 for None, " + if include_none else "") while True: try: - answer = input("Enter Index ({}q to quit): ".format( - "0 for None, " if include_none else "")) + answer = input(prompt) answer = answer.lower() - if answer in ["", "q"]: - print("Canceled") - return None + if answer == "q": + raise Canceled index = int(answer) if include_none and index == 0: return None @@ -135,7 +183,7 @@ def edit_templates(self, yaml2card: Callable[[str], CarddavObject], with contextlib.ExitStack() as stack: files = [stack.enter_context(self.write_temp_file(t)) for t in templates] - # Try to edit the files until we detect a modivication or the user + # Try to edit the files until we detect a modification or the user # aborts while True: if self.edit_files(*files) == EditState.unmodified: diff --git a/khard/helpers/typing.py b/khard/helpers/typing.py index e22c570b..1ea67c47 100644 --- a/khard/helpers/typing.py +++ b/khard/helpers/typing.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import List, Union +from typing import Dict, List, Union class ObjectType(Enum): @@ -14,16 +14,17 @@ class ObjectType(Enum): # some type aliases Date = Union[str, datetime] StrList = Union[str, List[str]] +PostAddress = Dict[str, str] def convert_to_vcard(name: str, value: StrList, constraint: ObjectType ) -> StrList: - """converts user input into vcard compatible data structures + """converts user input into vCard compatible data structures :param name: object name, only required for error messages :param value: user input - :param constraint: set the accepted return type for vcard attribute - :returns: cleaned user input, ready for vcard or a ValueError + :param constraint: set the accepted return type for vCard attribute + :returns: cleaned user input, ready for vCard or a ValueError """ if isinstance(value, str): if constraint == ObjectType.list: @@ -49,7 +50,7 @@ def list_to_string(input: Union[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) - :param delimiter: the deimiter to use when joining the items + :param delimiter: the delimiter to use when joining the items :returns: the recursively joined list """ if isinstance(input, list): diff --git a/khard/khard.py b/khard/khard.py index 2a488108..f90f7539 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -1,4 +1,4 @@ -"""Main application logic of khard includeing command line handling""" +"""Main application logic of khard including command line handling""" from argparse import Namespace import datetime @@ -86,10 +86,10 @@ def modify_existing_contact(old_contact: CarddavObject) -> None: def merge_existing_contacts(source_contact: CarddavObject, target_contact: CarddavObject, delete_source_contact: bool) -> None: - # show warning, if target vcard version is not 3.0 or 4.0 + # show warning, if target vCard version is not 3.0 or 4.0 if not version_check(target_contact, "target contact in which to merge"): return - # create temp files for each vcard + # create temp files for each vCard editor = interactive.Editor(config.editor, config.merge_editor) src_text = ("# merge from {}\n# Address book: {}\n# Vcard version: {}\n" "# if you want to cancel, exit without saving\n\n{}".format( @@ -224,38 +224,49 @@ def list_contacts(vcard_list: List[CarddavObject], fields: Iterable[str] = (), print(helpers.pretty_print(table)) -def list_with_headers(the_list: List, *headers: str) -> None: +def list_with_headers(the_list: List[str], *headers: str) -> None: table = [list(headers)] for row in the_list: table.append(row.split("\t")) print(helpers.pretty_print(table)) -def choose_address_book_from_list(header_string: str, - address_books: Union[AddressBookCollection, - List[VdirAddressBook]] +def choose_address_book_from_list(header: str, abooks: Union[ + AddressBookCollection, List[VdirAddressBook]] ) -> Optional[VdirAddressBook]: - if not address_books: + """Let the user select one of the given address books + + :param header: some text to print in front of the list + :param abooks: the address books from which to select + :returns: the selected address book + :raises interactive.Canceled: when the user canceled the selection + """ + if not abooks: return None - if len(address_books) == 1: - return address_books[0] - print(header_string) - list_address_books(address_books) - # For all intents and purposes of select() an AddressBookCollection can - # also be considered a List[VdirAddressBook]. - return interactive.select(cast(List[VdirAddressBook], address_books)) + if len(abooks) == 1: + return abooks[0] + print(header) + list_address_books(abooks) + return interactive.select(abooks) -def choose_vcard_from_list(header_string: str, vcard_list: List[CarddavObject], +def choose_vcard_from_list(header: str, vcards: List[CarddavObject], include_none: bool = False ) -> Optional[CarddavObject]: - if not vcard_list: + """Let the user select a contact from a list + + :param header: some text to print in front of the list + :param vcards: the contacts from which to select + :returns: the selected contact + :raises interactive.Canceled: when the user canceled the selection + """ + if not vcards: return None - if len(vcard_list) == 1 and not include_none: - return vcard_list[0] - print(header_string) - list_contacts(vcard_list) - return interactive.select(vcard_list, True) + if len(vcards) == 1 and not include_none: + return vcards[0] + print(header) + list_contacts(vcards) + return interactive.select(vcards, True) def get_contact_list(address_books: Union[VdirAddressBook, @@ -304,7 +315,7 @@ def sort_contacts(contacts: Iterable[CarddavObject], reverse: bool = False, def prepare_search_queries(args: Namespace) -> Dict[str, Query]: """Prepare the search query string from the given command line args. - Each address book can get a search query string to filter vcards before + Each address book can get a search query string to filter vCards before loading them. Depending on the question if the address book is used for source or target searches different queries have to be combined. @@ -356,30 +367,29 @@ def generate_contact_list(args: Namespace) -> List[CarddavObject]: return get_contact_list(args.addressbook, args.search_terms) -def new_subcommand(selected_address_books: AddressBookCollection, - input_from_stdin_or_file: str, open_editor: bool) -> None: +def new_subcommand(abooks: AddressBookCollection, data: str, open_editor: bool + ) -> None: """Create a new contact. - :param selected_address_books: a list of addressbooks that were selected on - the command line - :param input_from_stdin_or_file: the data for the new contact as a yaml - formatted string - :param open_editor: whether to open the new contact in the edior after + :param abooks: a list of address books that were selected on the command + line + :param data: the data for the new contact as a yaml formatted string + :param open_editor: whether to open the new contact in the editor after creation + :raises interactive.Canceled: when the user canceled a selection """ # ask for address book, in which to create the new contact - selected_address_book = choose_address_book_from_list( - "Select address book for new contact", selected_address_books) - if selected_address_book is None: + abook = choose_address_book_from_list( + "Select address book for new contact", abooks) + if abook is None: sys.exit("Error: address book list is empty") - # if there is some data in stdin - if input_from_stdin_or_file: - # create new contact from stdin + # if there is some data in stdin/the input file + if data: + # create new contact from stdin/the input file try: new_contact = CarddavObject.from_yaml( - selected_address_book, input_from_stdin_or_file, - config.private_objects, config.preferred_vcard_version, - config.localize_dates) + abook, data, config.private_objects, + config.preferred_vcard_version, config.localize_dates) except ValueError as err: sys.exit(str(err)) else: @@ -389,7 +399,7 @@ def new_subcommand(selected_address_books: AddressBookCollection, else: print("Creation successful\n\n{}".format(new_contact.pretty())) else: - create_new_contact(selected_address_book) + create_new_contact(abook) def add_email_to_contact(name: str, email_address: str, @@ -399,8 +409,9 @@ def add_email_to_contact(name: str, email_address: str, :param name: name of the contact :param email_address: email address of the contact - :param abooks: the addressbooks that were selected on the command line + :param abooks: the address books that were selected on the command line :param skip_already_added: skip if email_address is part of one or more contacts + :raises interactive.Canceled: when the user canceled a selection """ # email address @@ -447,7 +458,7 @@ def add_email_to_contact(name: str, email_address: str, elif len(name_parts) == 1: query = TermQuery(name) else: - term_query_list = [ TermQuery(part) for part in name_parts ] + term_query_list = [TermQuery(part) for part in name_parts] query = AndQuery( term_query_list[0], term_query_list[1], *term_query_list[2:]) found_vcard_list = get_contact_list(abooks, query) @@ -472,34 +483,35 @@ def add_email_to_contact(name: str, email_address: str, while True: if selected_vcard is None: if found_vcard_list: - answer = input("Contact selection cancelled (c/s/q): ") + message = "Contact selection cancelled" else: - answer = input("Nothing found for '{}' (c/s/q): " - .format(name)) - error_message = ('Please answer with "c" to create a new ' - 'contact, "s" to search for an existing ' - 'contact or "q" to quit') + message = "Nothing found for '{}'".format(name) + answer = interactive.ask(message, ["create", "search", "quit"]) else: - answer = input("Contact selected: {} (y/c/d/s/q): " - .format(selected_vcard)) - error_message = ('Please answer with "y" to proceed, ' - '"c" to create a new contact, "d" for details ' - 'of the selected contact, "s" to search ' - 'for an existing contact or "q" to quit') - answer = answer.lower() + answer = interactive.ask( + "Contact selected: {}".format(selected_vcard), + ["yes", "create", "details", "search", "quit"], + """You can enter one of these choices: + + yes proceed with selected contact + create create a new contact + details show details of selected contact + search search for a different contact + quit abort + """) if selected_vcard: - if answer == 'y': + if answer == 'yes': break_outer = True break - if answer == 'd': + if answer == 'details': print("\n{}".format(selected_vcard.pretty())) continue - if answer == 'c': + if answer == 'create': selected_vcard = None break_outer = True break - if answer == 's': + if answer == 'search': # save data previous_name = name previous_selected_vcard = selected_vcard @@ -513,10 +525,9 @@ def add_email_to_contact(name: str, email_address: str, name = input("Search for contact: ") manual_search = True break - if answer == 'q': + if answer == 'quit': print("Cancelled") return - print(error_message) if break_outer: # restore name @@ -671,9 +682,10 @@ def add_email_subcommand( """Add a new email address to contacts, creating new contacts if necessary. :param text: the input text to search for the new email - :param abooks: the addressbooks that were selected on the command line + :param abooks: the address books that were selected on the command line :param field: the header field to extract contacts from :param skip_already_added: skip already known email addresses + :raises interactive.Canceled: when the user canceled a selection """ email_addresses = find_email_addresses(text, fields) if not email_addresses: @@ -696,27 +708,27 @@ def birthdays_subcommand(vcard_list: List[CarddavObject], parsable: bool ) -> None: """Print birthday contact table. - :param vcard_list: the vcards to search for matching entries which should + :param vcard_list: the vCards to search for matching entries which should be printed - :param parsable: machine readable output: columns devided by tabulator (\t) + :param parsable: machine readable output: columns divided by tabulator (\t) """ # filter out contacts without a birthday date vcard_list = [vcard for vcard in vcard_list if vcard.birthday is not None] # sort by date (month and day) # The sort function should work for strings and datetime objects. All - # strings will besorted before any datetime objects. + # strings will be sorted before any datetime objects. vcard_list.sort(key=lambda x: (x.birthday.month, x.birthday.day) if isinstance(x.birthday, datetime.datetime) else (0, 0, x.birthday)) # add to string list - birthday_list = [] + birthday_list: List[str] = [] formatter = Formatter(config.display, config.preferred_email_address_type, config.preferred_phone_number_type, config.show_nicknames, parsable) for vcard in vcard_list: name = formatter.get_special_field(vcard, "name") if parsable: - # We did filter out None above but the typechecker does not know + # We did filter out None above but the type checker does not know # this. bday = cast(Union[str, datetime.datetime], vcard.birthday) if isinstance(bday, str): @@ -744,14 +756,14 @@ def phone_subcommand(search_terms: Query, vcard_list: List[CarddavObject], :param search_terms: used as search term to filter the contacts before printing - :param vcard_list: the vcards to search for matching entries which should + :param vcard_list: the vCards to search for matching entries which should be printed - :param parsable: machine readable output: columns devided by tabulator (\t) + :param parsable: machine readable output: columns divided by tabulator (\t) """ formatter = Formatter(config.display, config.preferred_email_address_type, config.preferred_phone_number_type, config.show_nicknames, parsable) - numbers = [] + numbers: List[str] = [] for vcard in vcard_list: field_line_list = [] for type, number_list in sorted(vcard.phone_numbers.items(), @@ -781,18 +793,18 @@ def phone_subcommand(search_terms: Query, vcard_list: List[CarddavObject], def post_address_subcommand(search_terms: Query, vcard_list: List[CarddavObject], parsable: bool ) -> None: - """Print a contact table. with all postal / mailing addresses + """Print a contact table with all postal / mailing addresses :param search_terms: used as search term to filter the contacts before printing - :param vcard_list: the vcards to search for matching entries which should + :param vcard_list: the vCards to search for matching entries which should be printed - :param parsable: machine readable output: columns devided by tabulator (\t) + :param parsable: machine readable output: columns divided by tabulator (\t) """ formatter = Formatter(config.display, config.preferred_email_address_type, config.preferred_phone_number_type, config.show_nicknames, parsable) - addresses = [] + addresses: List[str] = [] for vcard in vcard_list: name = formatter.get_special_field(vcard, "name") # create post address line list @@ -802,14 +814,14 @@ def post_address_subcommand(search_terms: Query, key=lambda k: k[0].lower()): for post_address in post_addresses: field_line_list.append( - "\t".join([ str(post_address), name, type ])) + "\t".join([str(post_address), name, type])) else: for type, formatted_addresses in sorted( vcard.get_formatted_post_addresses().items(), key=lambda k: k[0].lower()): for address in sorted(formatted_addresses): field_line_list.append( - "\t".join([ name, type, address ])) + "\t".join([name, type, address])) addresses += _filter_email_post_or_phone_number_results( search_terms, field_line_list) if addresses: @@ -838,15 +850,15 @@ def email_subcommand(search_terms: Query, vcard_list: List[CarddavObject], :param search_terms: used as search term to filter the contacts before printing - :param vcard_list: the vcards to search for matching entries which should + :param vcard_list: the vCards to search for matching entries which should be printed - :param parsable: machine readable output: columns devided by tabulator (\t) + :param parsable: machine readable output: columns divided by tabulator (\t) :param remove_first_line: remove first line (searching for '' ...) """ formatter = Formatter(config.display, config.preferred_email_address_type, config.preferred_phone_number_type, config.show_nicknames, parsable) - emails = [] + emails: List[str] = [] for vcard in vcard_list: field_line_list = [] for type, email_list in sorted(vcard.emails.items(), @@ -899,8 +911,8 @@ def list_subcommand(vcard_list: List[CarddavObject], parsable: bool, fields: List[str]) -> None: """Print a user friendly contacts table. - :param vcard_list: the vcards to print - :param parsable: machine readable output: columns devided by tabulator (\t) + :param vcard_list: the vCards to print + :param parsable: machine readable output: columns divided by tabulator (\t) :param fields: list of strings for field evaluation """ if not vcard_list: @@ -918,9 +930,9 @@ def modify_subcommand(selected_vcard: CarddavObject, :param selected_vcard: the contact to modify :param input_from_stdin_or_file: new data from stdin (or a file) that - should be incorperated into the contact, this should be a yaml + should be incorporated into the contact, this should be a yaml formatted string - :param open_editor: whether to open the new contact in the edior after + :param open_editor: whether to open the new contact in the editor after creation :param source: edit the source file or a yaml version? """ @@ -928,7 +940,7 @@ def modify_subcommand(selected_vcard: CarddavObject, editor = interactive.Editor(config.editor, config.merge_editor) editor.edit_files(selected_vcard.filename) return - # show warning, if vcard version of selected contact is not 3.0 or 4.0 + # show warning, if vCard version of selected contact is not 3.0 or 4.0 if not version_check(selected_vcard, "selected contact"): return # if there is some data in stdin @@ -957,7 +969,7 @@ def modify_subcommand(selected_vcard: CarddavObject, def remove_subcommand(selected_vcard: CarddavObject, force: bool) -> None: - """Remove a contact from the addressbook. + """Remove a contact from the address book. :param selected_vcard: the contact to delete :param force: delete without confirmation @@ -972,26 +984,27 @@ def remove_subcommand(selected_vcard: CarddavObject, force: bool) -> None: selected_vcard.formatted_name)) -def merge_subcommand(vcard_list: List[CarddavObject], +def merge_subcommand(vcards: List[CarddavObject], abooks: AddressBookCollection, search_terms: Query ) -> None: """Merge two contacts into one. - :param vcard_list: the vcards from which to choose contacts for mergeing - :param abooks: the addressbooks to use to find the target contact + :param vcards: the vCards from which to choose contacts for merging + :param abooks: the address books to use to find the target contact :param search_terms: the search terms to find the target contact + :raises interactive.Canceled: when the user canceled a selection """ # Find possible target contacts. target_vcards = get_contact_list(abooks, search_terms) - # get the source vcard, from which to merge + # get the source vCard, from which to merge source_vcard = choose_vcard_from_list("Select contact from which to merge", - vcard_list) + vcards) if source_vcard is None: sys.exit("Found no source contact for merging") else: print("Merge from {} from address book {}\n\n".format( source_vcard, source_vcard.address_book)) - # get the target vcard, into which to merge + # get the target vCard, into which to merge target_vcard = choose_vcard_from_list("Select contact into which to merge", target_vcards) if target_vcard is None: @@ -1006,18 +1019,19 @@ def merge_subcommand(vcard_list: List[CarddavObject], merge_existing_contacts(source_vcard, target_vcard, True) -def copy_or_move_subcommand(action: str, vcard_list: List[CarddavObject], +def copy_or_move_subcommand(action: str, vcards: List[CarddavObject], target_address_books: AddressBookCollection ) -> None: """Copy or move a contact to a different address book. :param action: the string "copy" or "move" to indicate what to do - :param vcard_list: the contact list from which to select one for the action + :param vcards: the contact list from which to select one for the action :param target_address_books: the target address books + :raises interactive.Canceled: when the user canceled a selection """ - # get the source vcard, which to copy or move + # get the source vCard, which to copy or move source_vcard = choose_vcard_from_list( - "Select contact to {}".format(action.title()), vcard_list) + "Select contact to {}".format(action.title()), vcards) if source_vcard is None: sys.exit("Found no contact") else: @@ -1057,27 +1071,25 @@ def copy_or_move_subcommand(action: str, vcard_list: List[CarddavObject], # source and target contacts are different # either overwrite the target one or merge into target contact print("The address book {} already contains the contact {}\n\n" - "Source\n\n{}\n\nTarget\n\n{}\n\nPossible actions:\n" - " a: {} anyway\n" - " m: Merge from source into target contact\n" - " o: Overwrite target contact\n" - " q: Quit".format(target_vcard.address_book, source_vcard, - source_vcard.pretty(), target_vcard.pretty(), - action.title())) + "Source\n\n{}\n\nTarget\n\n{}\n\n".format( + target_vcard.address_book, source_vcard, source_vcard.pretty(), + target_vcard.pretty())) while True: - input_string = input("Your choice: ") - if input_string.lower() == "a": + answer = interactive.ask( + "Possible actions", [action, "merge", "overwrite", "quit"], + "quit") + if answer == action: copy_contact(source_vcard, target_abook, action == "move") break - if input_string.lower() == "o": + if answer == "overwrite": copy_contact(source_vcard, target_abook, action == "move") target_vcard.delete_vcard_file() break - if input_string.lower() == "m": + if answer == "merge": merge_existing_contacts(source_vcard, target_vcard, action == "move") break - if input_string.lower() in ["", "q"]: + if answer == "quit": print("Canceled") break @@ -1150,14 +1162,8 @@ def main(argv: List[str] = sys.argv[1:]) -> None: except OSError: pass - if args.action == "new": - new_subcommand(args.addressbook, input_from_stdin_or_file, - args.open_editor) - elif args.action == "add-email": - add_email_subcommand(input_from_stdin_or_file, - args.addressbook, args.headers, - args.skip_already_added) - elif args.action == "birthdays": + # these listing commands do not require any user interaction + if args.action == "birthdays": birthdays_subcommand(vcard_list, args.parsable) elif args.action == "phone": phone_subcommand(args.search_terms, vcard_list, args.parsable) @@ -1168,32 +1174,45 @@ def main(argv: List[str] = sys.argv[1:]) -> None: args.parsable, args.remove_first_line) elif args.action == "list": list_subcommand(vcard_list, args.parsable, args.fields) - elif args.action in ["show", "edit", "remove"]: - selected_vcard = choose_vcard_from_list( - "Select contact for {} action".format(args.action.title()), - vcard_list) - if selected_vcard is None: - sys.exit("Found no contact") - if args.action == "show": - if args.format == "pretty": - output = selected_vcard.pretty() - elif args.format == "vcard": - output = open(selected_vcard.filename).read() - else: - output = "# Contact template for khard version {}\n" \ - "# Name: {}\n# Vcard version: {}\n\n{}".format( - khard_version, selected_vcard, - selected_vcard.version, - selected_vcard.to_yaml()) - args.output_file.write(output) - elif args.action == "edit": - modify_subcommand(selected_vcard, input_from_stdin_or_file, - args.open_editor, args.format == 'vcard') - elif args.action == "remove": - remove_subcommand(selected_vcard, args.force) - elif args.action == "merge": - merge_subcommand(vcard_list, args.target_addressbook, - args.target_contact) - elif args.action in ["copy", "move"]: - copy_or_move_subcommand( - args.action, vcard_list, args.target_addressbook) + + else: + # these commands require user interaction + try: + if args.action == "new": + new_subcommand(args.addressbook, input_from_stdin_or_file, + args.open_editor) + elif args.action == "add-email": + add_email_subcommand(input_from_stdin_or_file, + args.addressbook, args.headers, + args.skip_already_added) + elif args.action in ["show", "edit", "remove"]: + selected_vcard = choose_vcard_from_list( + "Select contact for {} action".format(args.action.title()), + vcard_list) + if selected_vcard is None: + sys.exit("Found no contact") + if args.action == "show": + if args.format == "pretty": + output = selected_vcard.pretty() + elif args.format == "vcard": + output = open(selected_vcard.filename).read() + else: + output = "# Contact template for khard version {}\n" \ + "# Name: {}\n# Vcard version: {}\n\n{}".format( + khard_version, selected_vcard, + selected_vcard.version, + selected_vcard.to_yaml()) + args.output_file.write(output) + elif args.action == "edit": + modify_subcommand(selected_vcard, input_from_stdin_or_file, + args.open_editor, args.format == 'vcard') + elif args.action == "remove": + remove_subcommand(selected_vcard, args.force) + elif args.action == "merge": + merge_subcommand(vcard_list, args.target_addressbook, + args.target_contact) + elif args.action in ["copy", "move"]: + copy_or_move_subcommand( + args.action, vcard_list, args.target_addressbook) + except interactive.Canceled as ex: + sys.exit(str(ex)) diff --git a/khard/query.py b/khard/query.py index 4789e5c2..7c398553 100644 --- a/khard/query.py +++ b/khard/query.py @@ -167,7 +167,7 @@ def __str__(self) -> str: class AndQuery(Query): - """A query to combine multible queries with "and".""" + """A query to combine multiple queries with "and".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -198,7 +198,7 @@ def __str__(self) -> str: class OrQuery(Query): - """A query to combine multible queries with "or".""" + """A query to combine multiple queries with "or".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -229,7 +229,7 @@ def __str__(self) -> str: class NameQuery(TermQuery): - """special query to match any kind of name field of a vcard""" + """special query to match any kind of name field of a vCard""" def __init__(self, term: str) -> None: super().__init__(term) @@ -301,12 +301,12 @@ def _match_phone_number(self, number: str) -> bool: # number: +49123456789 return self._term_only_digits in number elif self._term_only_digits.startswith("+") and number.startswith("0"): - # asume, that _term_only_digits contains a complete phone number + # assume, that _term_only_digits contains a complete phone number # _term_only_digits: +49123456789 # number: 0123456789 return number[1:] in self._term_only_digits elif self._term_only_digits.startswith("0") and number.startswith("+"): - # can't asume, that _term_only_digits contains a complete phone number + # can't assume, that _term_only_digits contains a complete phone number # _term_only_digits: 0123456789 # number: +49123456789 if len(self._term_only_digits) >= 5: diff --git a/misc/twinkle/scripts/incoming_call.py b/misc/twinkle/scripts/incoming_call.py index a32e0402..51a38cfc 100755 --- a/misc/twinkle/scripts/incoming_call.py +++ b/misc/twinkle/scripts/incoming_call.py @@ -64,7 +64,7 @@ def create_ringtone(caller_id): from_hdr = os.environ["SIP_FROM"] # parse the caller ID of the string caller_id = get_caller_id(from_hdr) - # look into the addressbook + # look into the address book if caller_id != "": caller_id = caller_from_addressbook(caller_id) else: diff --git a/misc/zsh/_khard b/misc/zsh/_khard index 155c57f1..abb21528 100644 --- a/misc/zsh/_khard +++ b/misc/zsh/_khard @@ -13,7 +13,7 @@ # More information at http://is.muni.cz/www/xsiska2/2014/08/05/generating-completing-functions.html -# Define a helper function to complete addressbook names. +# Define a helper function to complete address book names. function _khard_addressbook_names () { local expl _sequence _wanted addressbooks expl "addressbook" compadd - \ @@ -39,7 +39,7 @@ _arguments -C -s \ '(- *)'{-v,--version}'[show version information]' \ '(-c)'{-c+,--config=}'[config file to use]:config file:_files' \ '--debug[enable debug output]' \ - '--skip-unparsable[skip unparsable vcard files]' \ + '--skip-unparsable[skip unparsable vCard files]' \ ':subcommand:->subcommand' \ '*::option:->options' && ret=0 @@ -49,16 +49,16 @@ case $state in local -a subcommands_array subcommands_array=( add-email:'add email address from email header to a contact' - {addressbooks,abooks}:'list available addressbooks' + {addressbooks,abooks}:'list available address books' {birthdays,bdays}:'list birthdays' - {copy,cp}:'copy a contact to another addressbook' + {copy,cp}:'copy a contact to another address book' {details,show}:'show details for a contact' email:'list email addresses' {filename,file}':list internal file names' {list,ls}:'list all (selected) contacts' merge:'merge two contacts' {modify,edit,ed}:'edit a contact' - {move,mv}:'move a contact to another addressbook' + {move,mv}:'move a contact to another address book' {new,add}:'add a new contact' phone:'list phone numbers' {postaddress,postaddr,post}:'list post addresses' @@ -73,18 +73,18 @@ case $state in # address book options local -a default_addressbook_options new_addressbook_options copy_move_addressbook_options merge_addressbook_options default_addressbook_options=( - '(-a)'{-a+,--addressbook=}'[specify addressbooks to narrow the list of contacts]:addressbook:_khard_addressbook_names' + '(-a)'{-a+,--addressbook=}'[specify address books to narrow the list of contacts]:addressbook:_khard_addressbook_names' ) new_addressbook_options=( - '(-a)'{-a+,--addressbook=}'[specify addressbook in which to create new contact]:addressbook:_khard_addressbook_names' + '(-a)'{-a+,--addressbook=}'[specify address book in which to create new contact]:addressbook:_khard_addressbook_names' ) copy_move_addressbook_options=( - '(-a)'{-a+,--addressbook=}'[specify addressbooks to narrow the list of contacts]:addressbook:_khard_addressbook_names' - '(-A)'{-A+,--target-addressbook=}'[specify target addressbook in which to copy / move]:addressbook:_khard_addressbook_names' + '(-a)'{-a+,--addressbook=}'[specify address books to narrow the list of contacts]:addressbook:_khard_addressbook_names' + '(-A)'{-A+,--target-addressbook=}'[specify target address book in which to copy / move]:addressbook:_khard_addressbook_names' ) merge_addressbook_options=( - '(-a)'{-a+,--addressbook=}'[specify addressbooks to narrow the list of source contacts]:addressbook:_khard_addressbook_names' - '(-A)'{-A+,--target-addressbook=}'[specify addressbooks to narrow the list of target contacts]:addressbook:_khard_addressbook_names' + '(-a)'{-a+,--addressbook=}'[specify address books to narrow the list of source contacts]:addressbook:_khard_addressbook_names' + '(-A)'{-A+,--target-addressbook=}'[specify address books to narrow the list of target contacts]:addressbook:_khard_addressbook_names' ) # input file options local -a email_header_input_options template_file_input_options @@ -107,16 +107,11 @@ case $state in local -a default_search_options merge_search_options default_search_options=( '(-f)'{-f,--search-in-source-files}'[look into source vcf files to speed up search queries in large address books]' - '(-e)'{-e,--strict-search}'[narrow contact search to name field]' - '(-u)'{-u+,--uid=}'[select contact by uid]:uid' '*: :_guard "^-*" "search term"' ) merge_search_options=( '(-f)'{-f,--search-in-source-files}'[look into source vcf files to speed up search queries in large address books]' - '(-e)'{-e,--strict-search}'[narrow contact search to name fields]' '(-t)'{-t+,--target-contact=}'[search in all fields to find matching target contact]:search string' - '(-u)'{-u+,--uid=}'[select source contact by uid]:uid' - '(-U)'{-U+,--target-uid=}'[select target contact by uid]:uid' '*: :_guard "^-*" "search term"' ) @@ -167,12 +162,12 @@ case $state in new|add) options+=( $new_addressbook_options $template_file_input_options - '--vcard-version=[select preferred vcard version for new contact]:version:(3.0 4.0)' + '--vcard-version=[select preferred vCard version for new contact]:version:(3.0 4.0)' );; add-email) options+=( $default_addressbook_options $email_header_input_options $default_search_options $sort_options - '--vcard-version=[select preferred vcard version for new contact]:version:(3.0 4.0)' + '--vcard-version=[select preferred vCard version for new contact]:version:(3.0 4.0)' );; copy|cp|move|mv) options+=( diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3c6aaac9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[project] +name = "khard" +dynamic = ["version"] +authors = [ + { name = "Eric Scheibler", email = 'email@eric-scheibler.de' }, +] +description = "A console address book manager" +readme = "README.md" +requires-python = ">=3.8" + +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Topic :: Utilities", + "Topic :: Communications :: Email :: Address Book", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Intended Audience :: End Users/Desktop", + "Operating System :: POSIX", + "Programming Language :: Python :: 3 :: Only", +] + +keywords = ["vcard", "console", "addressbook"] +license = {text = "GPL"} + +dependencies = [ + "atomicwrites", + "configobj", + "ruamel.yaml", + "unidecode", + "vobject" +] + +[project.optional-dependencies] +doc = ['sphinx', 'sphinx-autoapi', 'sphinx-autodoc-typehints'] + +[project.urls] +homepage = "https://github.com/lucc/khard" +documentation = "https://khard.readthedocs.io/en/latest/" +repository = "https://github.com/lucc/khard.git" +changelog = "https://github.com/lucc/khard/blob/develop/CHANGES" + +[project.scripts] +khard = "khard.khard:main" + +[build-system] +requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ['khard', 'khard.helpers'] + +[tool.setuptools_scm] +write_to = "khard/version.py" + +[tool.mypy] +packages = "khard" +warn_unused_configs = true +warn_unused_ignores = true +warn_redundant_casts = true + +[[tool.mypy.overrides]] +# These do not provide type anotations or stub files +module = [ + "atomicwrites", + "configobj", + "configobj.validate", + "validate", + "vobject", + "vobject.base", +] +ignore_missing_imports = true + +[tool.pylint.main] +py-version = "3.8" +ignore-paths = ["khard/version.py"] + +[tool.pylint."messages control"] +disable = ["consider-using-f-string"] diff --git a/setup.py b/setup.py index 6c3175bd..881d40cd 100644 --- a/setup.py +++ b/setup.py @@ -7,48 +7,4 @@ from setuptools import setup -with open('README.md', 'rb') as f: - readme = f.read().decode("utf-8") - -setup( - name='khard', - author='Eric Scheibler', - author_email='email@eric-scheibler.de', - url='https://github.com/lucc/khard/', - description='A console address book manager', - long_description=readme, - long_description_content_type='text/markdown', - license='GPL', - keywords='vcard console addressbook', - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Topic :: Utilities", - "Topic :: Communications :: Email :: Address Book", - "License :: OSI Approved :: GNU General Public License (GPL)", - "Intended Audience :: End Users/Desktop", - "Operating System :: POSIX", - "Programming Language :: Python :: 3 :: Only", - ], - install_requires=[ - 'atomicwrites', - 'configobj', - 'ruamel.yaml', - 'unidecode', - 'vobject' - ], - extras_require={'doc': [ - 'sphinx', - 'sphinx-autoapi', - 'sphinx-autodoc-typehints' - ]}, - use_scm_version={'write_to': 'khard/version.py'}, - setup_requires=['setuptools_scm'], - packages=['khard', 'khard.helpers'], - package_data={'khard': ['data/*']}, - entry_points={'console_scripts': ['khard = khard.khard:main']}, - test_suite="test", - # we use type annotations of unset variables which needs 3.6 - python_requires=">=3.7", - include_package_data=True, -) +setup(test_suite="test") diff --git a/test/__init__.py b/test/__init__.py index 8119fa15..b58f15b5 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -2,9 +2,4 @@ hence make it possible to run all unittests from the top level direcotry with python -m unittest [discover] - -and - - python setup.py test - """ diff --git a/test/test_address_book.py b/test/test_address_book.py index 258d51a1..3a913ccb 100644 --- a/test/test_address_book.py +++ b/test/test_address_book.py @@ -121,7 +121,7 @@ def test_unparsable_files_can_be_skipped(self): @mock.patch.dict("os.environ", clear=True) def test_do_not_expand_env_var_that_is_unset(self): # Unset env vars shouldn't expand. - with self.assertRaises(FileNotFoundError): + with self.assertRaises(NotADirectoryError): address_book.VdirAddressBook( "test", "test/fixture/test.abook${}".format("KHARD_FOO")) diff --git a/test/test_command_line_interface.py b/test/test_command_line_interface.py index 5c6ab2c2..f24f2852 100644 --- a/test/test_command_line_interface.py +++ b/test/test_command_line_interface.py @@ -6,9 +6,8 @@ """ # pylint: disable=missing-docstring -# TODO We are still missing high level tests for the add-email and merge -# subcommands. They depend heavily on user interaction and are hard to test in -# their current form. +# TODO We are still missing high level tests for the merge subcommand. It +# depends heavily on user interaction and is hard to test in its current form. import io import pathlib @@ -21,7 +20,7 @@ from khard import cli from khard import config -from khard.helpers.interactive import EditState, Editor +from khard.helpers.interactive import Editor from khard import khard from .helpers import TmpConfig, mock_stream @@ -39,8 +38,8 @@ class HelpOption(unittest.TestCase): def _test(self, args, expect): """Test the command line args and compare the prefix of the output.""" - with self.assertRaises(SystemExit): - with mock_stream() as stdout: + with mock_stream() as stdout: + with self.assertRaises(SystemExit): cli.parse_args(args) text = stdout.getvalue() self.assertRegex(text, expect) @@ -165,8 +164,8 @@ def test_case_of_search_terms_does_not_matter(self): self.assertListEqual(text2, expected) def test_regex_special_chars_are_not_special(self): - with self.assertRaises(SystemExit): - with mock_stream() as stdout: + with mock_stream() as stdout: + with self.assertRaises(SystemExit): khard.main(['list', 'uid.']) self.assertEqual(stdout.getvalue(), "Found no contacts\n") @@ -487,8 +486,7 @@ def test_merge_with_exact_search_terms(self): def test_merge_with_exact_uid_search_terms(self): with TmpConfig(["contact1.vcf", "contact2.vcf"]): with mock.patch('khard.khard.merge_existing_contacts') as merge: - run_main("merge", "--uid", "testuid1", "--target-uid", - "testuid2") + run_main("merge", "uid:testuid1", "--target", "uid:testuid2") merge.assert_called_once() # unpack the call arguments call = merge.mock_calls[0] @@ -503,9 +501,6 @@ def test_merge_with_exact_uid_search_terms(self): class AddEmail(unittest.TestCase): - # FIXME the new code from fdc441cf asks for confirmation in - # khard.add_email_to_contact on line 419 - @unittest.skip("unexpected read from stdin blocks the test") @TmpConfig(["contact1.vcf", "contact2.vcf"]) def test_contact_is_found_if_name_matches(self): email = [ @@ -517,12 +512,49 @@ def test_contact_is_found_if_name_matches(self): with tempfile.NamedTemporaryFile("w") as tmp: tmp.writelines(email) tmp.flush() - with mock.patch("khard.khard.confirm", lambda x: True): - with mock.patch("builtins.input", lambda x: ""): - run_main("add-email", "--input-file", tmp.name) - stdout = run_main("list", "--fields=emails.internet.0") - addr = stdout.getvalue().splitlines()[-1].strip() - self.assertEqual(addr, "third@example.com") + with mock.patch("builtins.input", + mock.Mock(side_effect=["y", "y", ""])): + run_main("add-email", "--input-file", tmp.name) + emails = khard.config.abooks.get_short_uid_dict()["testuid2"].emails + self.assertEqual(emails["internet"][0], "third@example.com") + + @TmpConfig(["contact1.vcf", "contact2.vcf"]) + def test_adding_several_email_addresses(self): + email = [ + "From: third \n", + "To: anybody@example.com\n", + "\n", + "text\n" + ] + with tempfile.NamedTemporaryFile("w") as tmp: + tmp.writelines(email) + tmp.flush() + with mock.patch("builtins.input", mock.Mock(side_effect=[ + "y", "y", "label1", "y", "third contact", "y", "label2"])): + run_main("add-email", "--headers=from,to", "--input-file", + tmp.name) + emails = khard.config.abooks.get_short_uid_dict()["testuid2"].emails + self.assertEqual(emails["label1"][0], "third@example.com") + self.assertEqual(emails["label2"][0], "anybody@example.com") + + @TmpConfig(["contact1.vcf", "contact2.vcf"]) + def test_email_addresses_can_be_skipped(self): + email = [ + "From: third \n", + "To: anybody@example.com\n", + "\n", + "text\n" + ] + with tempfile.NamedTemporaryFile("w") as tmp: + tmp.writelines(email) + tmp.flush() + with mock.patch("builtins.input", lambda _: "n"): + run_main("add-email", "--input-file", tmp.name) + contacts = khard.config.abooks.get_short_uid_dict().values() + emails1 = [c.emails for c in contacts if c.emails] + emails2 = [list(e.values()) for e in emails1] + emails = [eee for e in emails2 for ee in e for eee in ee] + self.assertNotIn("third@example.com", emails) if __name__ == "__main__": diff --git a/test/test_helpers.py b/test/test_helpers.py index 17a88916..16eb10eb 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -20,6 +20,36 @@ def test_empty_strings_produce_empty_values(self): result = helpers.convert_to_yaml("Note", "", 0, 5, True) self.assertListEqual(result, ["Note : "]) + def test_preparing_multiple_addresses_with_same_label_for_yaml_conversion_returns_all_entries(self): + input = {'home': [{'street': 'street 1', + 'city': 'city1', + 'code': 'zip1', + 'country': ''}, + {'street': 'street 2', + 'city': 'city2', + 'code': 'zip2', + 'country': ''}]} + expected = [{'Street': 'street 1', + 'City': 'city1', + 'Code': 'zip1', + 'Country': None}, + {'Street': 'street 2', + 'City': 'city2', + 'Code': 'zip2', + 'Country': None}] + actual = helpers.yaml_addresses(input, ["Street", "Code", "City", + "Country"]) + self.assertEqual(expected, actual["home"]) + + def test_preparing_single_addresse_for_yaml_conversion_returns_dict_not_list(self): + input = {'home': [{'street': 'street', 'city': 'city', 'code': 'zip', + 'country': ''}]} + expected = {'Street': 'street', 'City': 'city', 'Code': 'zip', + 'Country': None} + actual = helpers.yaml_addresses(input, ["Street", "Code", "City", + "Country"]) + self.assertEqual(expected, actual["home"]) + if __name__ == "__main__": unittest.main() diff --git a/test/test_helpers_interactive.py b/test/test_helpers_interactive.py index d8e35f9a..409fd3e7 100644 --- a/test/test_helpers_interactive.py +++ b/test/test_helpers_interactive.py @@ -8,9 +8,40 @@ from .helpers import mock_stream +class Ask(unittest.TestCase): + + def test_accepts_on_of_the_given_options(self): + with mock.patch("builtins.input", lambda _: "foo"): + actual = interactive.ask("message", ["foo", "bar"]) + self.assertEqual("foo", actual) + + def test_does_not_accept_answers_not_in_choices(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["baz", "foo"])): + actual = interactive.ask("message", ["foo", "bar"]) + self.assertEqual("foo", actual) + + def test_default_is_accepted_on_empty_input(self): + with mock.patch("builtins.input", lambda _: ""): + actual = interactive.ask("message", ["foo", "bar"], "baz") + self.assertEqual("baz", actual) + + def test_accepts_prefix_match(self): + with mock.patch("builtins.input", lambda _: "f"): + actual = interactive.ask("message", ["foo", "bar"]) + self.assertEqual("foo", actual) + + def test_only_accepts_unique_prefix_match(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["ba", "bar"])): + with mock_stream() as stdout: + actual = interactive.ask("message", ["baz", "bar"]) + stdout = stdout.getvalue() + self.assertEqual("bar", actual) + self.assertIn("not specific enough", stdout) + + class Select(unittest.TestCase): - def _test(expected, include_none=None): + def _test(self, include_none=None): input_list = ["a", "b", "c"] if include_none is None: return interactive.select(input_list) @@ -18,7 +49,7 @@ def _test(expected, include_none=None): return interactive.select(input_list, include_none) def test_selection_index_is_1_based(self): - with mock.patch("builtins.input", lambda x: "1"): + with mock.patch("builtins.input", lambda _: "1"): actual = self._test() self.assertEqual(actual, "a") @@ -40,31 +71,54 @@ def test_out_of_bounds_repeats(self): "or q to quit.\n") self.assertEqual(actual, "b") + def test_index_0_is_not_accepted(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["0", "2"])): + with mock_stream() as stdout: + actual = self._test() + stdout = stdout.getvalue() + self.assertEqual(stdout, "Please enter an index value between 1 and 3 " + "or q to quit.\n") + self.assertEqual(actual, "b") + + def test_index_0_is_accepted_with_include_none(self): + with mock.patch("builtins.input", lambda _: "0"): + actual = self._test(True) + self.assertIsNone(actual) + + def test_empty_input_prints_a_message_and_repeats(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["", "2"])): + with mock_stream() as stdout: + actual = self._test() + stdout = stdout.getvalue() + self.assertEqual(stdout, "Please enter an index value between 1 and 3 " + "or q to quit.\n") + self.assertEqual(actual, "b") + class Confirm(unittest.TestCase): def test_y_is_true(self): - with mock.patch("builtins.input", lambda x: "y"): + with mock.patch("builtins.input", lambda _: "y"): self.assertTrue(interactive.confirm("")) def test_n_is_false(self): - with mock.patch("builtins.input", lambda x: "n"): + with mock.patch("builtins.input", lambda _: "n"): self.assertFalse(interactive.confirm("")) def test_Y_is_true(self): - with mock.patch("builtins.input", lambda x: "Y"): + with mock.patch("builtins.input", lambda _: "Y"): self.assertTrue(interactive.confirm("")) def test_N_is_false(self): - with mock.patch("builtins.input", lambda x: "N"): + with mock.patch("builtins.input", lambda _: "N"): self.assertFalse(interactive.confirm("")) - def test_full_word_yes_is_not_accepted(self): - with mock.patch("builtins.input", mock.Mock(side_effect=["yes", "n"])): + def test_empty_input_is_no(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["", "y"])): with mock_stream(): self.assertFalse(interactive.confirm("")) - def test_full_word_no_is_not_accepted(self): - with mock.patch("builtins.input", mock.Mock(side_effect=["no", "y"])): + def test_empty_input_can_be_forbidden_with_argument(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["", "y"])): with mock_stream(): - self.assertTrue(interactive.confirm("")) + self.assertTrue(interactive.confirm("", False)) diff --git a/test/test_khard.py b/test/test_khard.py index f3bb807a..5895cf69 100644 --- a/test/test_khard.py +++ b/test/test_khard.py @@ -49,21 +49,21 @@ def test_no_search_terms_result_in_any_queries(self): self.assertEqual(expected, prepared["foo"]) -class TestAddEmail(unittest.TestCase): +class TestFindEmailAddress(unittest.TestCase): - def test_find_email_addresses_empty_text_finds_none(self): + def test_empty_text_finds_none(self): text = "" addrs = find_email_addresses(text, ["from"]) self.assertEqual([], addrs) - def test_find_email_addresses_single_header_finds_one_address(self): + def test_single_header_finds_one_address(self): text = """From: John Doe """ addrs = find_email_addresses(text, ["from"]) expected = [Address(display_name="John Doe", username="jdoe", domain="machine.example")] self.assertEqual(expected, addrs) - def test_find_email_addresses_single_header_finds_multiple_addresses(self): + def test_single_header_finds_multiple_addresses(self): text = """From: John Doe , \ Mary Smith """ addrs = find_email_addresses(text, ["from"]) @@ -78,14 +78,14 @@ def test_find_email_addresses_single_header_finds_multiple_addresses(self): domain="example.net")] self.assertEqual(expected, addrs) - def test_find_email_addresses_non_address_header_finds_none(self): + def test_non_address_header_finds_none(self): text = "From: John Doe , " \ "Mary Smith \nOther: test" addrs = find_email_addresses(text, ["other"]) expected = [] self.assertEqual(expected, addrs) - def test_find_email_addresses_multiple_headers_finds_some(self): + def test_multiple_headers_finds_some(self): text = "From: John Doe , " \ "Mary Smith \nOther: test" addrs = find_email_addresses(text, ["other", "from"]) @@ -100,7 +100,7 @@ def test_find_email_addresses_multiple_headers_finds_some(self): domain="example.net")] self.assertEqual(expected, addrs) - def test_find_email_addresses_multiple_headers_finds_all(self): + def test_multiple_headers_finds_all(self): text = "From: John Doe , " \ "Mary Smith \n" \ "To: Michael Jones " @@ -120,7 +120,7 @@ def test_find_email_addresses_multiple_headers_finds_all(self): domain="example.net")] self.assertEqual(expected, addrs) - def test_find_email_addresses_finds_all_emails(self): + def test_finds_all_emails(self): text = "From: John Doe , " \ "Mary Smith \n" \ "To: Michael Jones " @@ -140,7 +140,7 @@ def test_find_email_addresses_finds_all_emails(self): domain="machine.example")] self.assertEqual(expected, addrs) - def test_find_email_addresses_finds_all_emails_with_other_headers_too( + def test_finds_all_emails_with_other_headers_too( self): text = "From: John Doe , " \ "Mary Smith \n" \ diff --git a/test/test_vcard_wrapper.py b/test/test_vcard_wrapper.py index d9e2a226..4ff0d46f 100644 --- a/test/test_vcard_wrapper.py +++ b/test/test_vcard_wrapper.py @@ -349,7 +349,6 @@ def test_adding_preferred_address(self): wrapper._add_post_address('home', *['home1 ' + c for c in components]) wrapper._add_post_address('pref,home', *['home2 ' + c for c in components]) - expected_work = {item: 'work ' + item for item in components} expected_home2 = {item: 'home2 ' + item for item in components} expected_home1 = {item: 'home1 ' + item for item in components} self.assertDictEqual( diff --git a/test/test_yaml_editable.py b/test/test_yaml_editable.py index b9460131..5e342b2c 100644 --- a/test/test_yaml_editable.py +++ b/test/test_yaml_editable.py @@ -20,6 +20,33 @@ def test_yaml_quoted_special_characters(self): yaml_dump = yaml_editable.to_yaml() self.assertIn("'@khard'", yaml_dump) + def test_dumping_multiple_home_addresses_to_yaml(self): + yaml_editable = TestYAMLEditable() + yaml_editable._add_post_address("home", "", "", "street 1", "zip1", + "city1", "", "") + yaml_editable._add_post_address("home", "", "", "street 2", "zip2", + "city2", "", "") + yaml_dump = yaml_editable.to_yaml() + self.assertIn("zip1", yaml_dump) + self.assertIn("zip2", yaml_dump) + + def test_dumping_multiple_home_phone_number_to_yaml(self): + yaml_editable = TestYAMLEditable() + yaml_editable._add_phone_number("home", "1234567890") + yaml_editable._add_phone_number("home", "0987654321") + yaml_dump = yaml_editable.to_yaml() + self.assertIn("1234567890", yaml_dump) + self.assertIn("0987654321", yaml_dump) + + def test_dumping_multiple_home_email_addresses_to_yaml(self): + yaml_editable = TestYAMLEditable() + yaml_editable.add_email("home", "home1@example.org") + yaml_editable.add_email("home", "home2@example.org") + yaml_dump = yaml_editable.to_yaml() + self.assertIn("home1", yaml_dump) + self.assertIn("home2", yaml_dump) + + class ExceptionHandling(unittest.TestCase):