diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6eca59a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: ci + +on: [push, pull_request] + +jobs: + + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + 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: Run tests + run: python setup.py test + + type-checker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install . + python -m pip install mypy + python -m pip install types-atomicwrites + - name: Run the type checker + run: mypy --ignore-missing-imports khard + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .[doc] + - name: Build the documentation + 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/.gitignore b/.gitignore index 2ee52726..0b0f333e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ doc/source/examples/template.yaml .pytest_cache __pycache__ khard.egg-info +result diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 912c1377..00000000 --- a/.travis.yml +++ /dev/null @@ -1,35 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" - - "nightly" - - "pypy3" # this is 3.6 -env: - - JOB=tests - -jobs: - include: - - python: "3.8" - env: - - JOB=check - - python: "3.8" - env: - - JOB=docs - allow_failures: - - python: "nightly" - - python: "pypy3" - -install: | - case $JOB in - tests) pip install .;; - docs) pip install .[doc];; - check) pip install mypy;; - esac - -script: | - case $JOB in - tests) python setup.py test;; - docs) python setup.py build; make -C doc html man;; - check) mypy --ignore-missing-imports khard;; - esac diff --git a/CHANGES b/CHANGES index 7f3ff285..be1307c8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,21 @@ Change Log ========== +v0.18.0: 2022-12-10 +- Move project home on GitHub from @scheibler to @lucc +- Catch exceptions when loading the config (#294) +- Split $EDITOR env variable on spaces (#314) +- Add special phone number field query +- Add-email command improved: Attach email address to an existing contact +- Add-email command: new option --skip-already-added +- Add partial support for KIND: search query, display in details and listing (#309) +- Add show_kinds config option (#309) +- Add nix flake +- Remove support for python 3.6 +- Remove deprecated subcommands "export" and "source" +- Switch from Travis to GitHub CI + + v0.17.0: 2020-08-13 - Do not modify (clean up) search query to find more matches (4583efd) - Remove special search handling for phone numbers (a570a85) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6edfc1c7..9fe70eb7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -41,7 +41,7 @@ Please stick to the following standards when you open pull requests: - Khard has a test suite, please provide tests for bugs that you fix and also for new code and new features that are introduced. - Please verify *all* tests pass before sending a pull request, they will be - checked again by travis but it might be a lot faster to check locally first: + checked again in CI but it might be a lot faster to check locally first: |travis| Development @@ -71,9 +71,9 @@ to do so. .. _master: https://github.com/lucc/khard/tree/master .. _PEP 8: https://www.python.org/dev/peps/pep-0008/ .. _pylint: https://pylint.readthedocs.io/en/latest/ -.. |travis| image:: https://travis-ci.org/scheibler/khard.svg?branch=develop - :target: https://travis-ci.org/scheibler/khard - :alt: build status +.. |travis| image:: https://github.com/lucc/khard/actions/workflows/ci.yml/badge.svg + :target: https://github.com/lucc/khard/actions/workflows/ci.yml + :alt: ci status .. _Vincent's branching model: http://nvie.com/posts/a-successful-git-branching-model/ .. _virtualenv: https://virtualenv.pypa.io/en/stable/ diff --git a/README.md b/README.md index 1e0f450f..8676d9b0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ khard ===== Khard is an address book for the Unix console. It creates, reads, modifies and -removes carddav address book entries at your local machine. Khard is also +removes vCard address book entries at your local machine. Khard is also compatible to the email clients mutt and alot and the SIP client twinkle. You can find more information about khard and the whole synchronization process [here][blog]. @@ -47,7 +47,7 @@ the `doc` directory. Development ----------- -[![Build Status][travis-badge]][travis] +[![ci-badge]][ci] Khard is developed [on GitHub](https://github.com/lucc/khard) where you are welcome to post [bug reports](https://github.com/lucc/khard/issues) @@ -74,5 +74,5 @@ If you need a console based calendar too, try out [repos-badge]: https://repology.org/badge/tiny-repos/khard.svg [docs]: https://khard.readthedocs.io/en/latest/ [docs-badge]: https://readthedocs.org/projects/khard/badge/?version=latest - [travis]: https://travis-ci.org/scheibler/khard - [travis-badge]: https://travis-ci.org/scheibler/khard.svg?branch=develop + [ci]: https://github.com/lucc/khard/actions/workflows/ci.yml + [ci-badge]: https://github.com/lucc/khard/actions/workflows/ci.yml/badge.svg diff --git a/doc/source/conf.py b/doc/source/conf.py index ba778f31..3658d89f 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -48,11 +48,11 @@ def update_template_file(): # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'autoapi.extension', - 'sphinx.ext.autodoc', + 'autoapi.extension', # https://sphinx-autoapi.readthedocs.io/en/latest/ + 'sphinx.ext.autodoc', # https://pypi.org/project/sphinx-autodoc-typehints/ 'sphinx.ext.autosectionlabel', 'sphinx.ext.todo', - 'sphinx_autodoc_typehints', + 'sphinx_autodoc_typehints', # https://pypi.org/project/sphinx-autodoc-typehints/ ] autoapi_type = 'python' @@ -89,7 +89,7 @@ def update_template_file(): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -134,7 +134,7 @@ def update_template_file(): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('man/khard', 'khard', 'Console carddav client', '', 1), + ('man/khard', 'khard', 'Console address book manager', '', 1), ('man/khard.conf', 'khard.conf', 'configuration file for khard', '', 5), ] diff --git a/doc/source/examples/khard.conf.example b/doc/source/examples/khard.conf.example index 03cff9dd..956093f8 100644 --- a/doc/source/examples/khard.conf.example +++ b/doc/source/examples/khard.conf.example @@ -27,6 +27,8 @@ reverse = no show_nicknames = no # show uid table column: yes / no show_uids = yes +# show kind table column: yes / no +show_kinds = no # sort by first or last name: first_name / last_name / formatted_name sort = last_name # localize dates: yes / no diff --git a/doc/source/index.rst b/doc/source/index.rst index 70fba6ad..7b0a25d3 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -13,8 +13,8 @@ Welcome to khard's documentation! indices Khard is an address book for the Unix command line. It can read, create, -modify and delete carddav address book entries. Khard only works with a local -store of VCARD files. It is intended to be used in conjunction with other +modify and delete vCard address book entries. Khard only works with a local +store of vCard files. It is intended to be used in conjunction with other programs like an email client, text editor, vdir synchronizer or VOIP client. @@ -36,6 +36,9 @@ manually you can use the release from `PyPi`_: If you want to help the development or need more advanced installation instructions see :doc:`contributing`. +If you need a tarball use the one from `PyPi`_ and not from the Github release +page. These are missing an auto generated python file. + .. _PyPi: https://pypi.python.org/pypi/khard/ Configuration diff --git a/doc/source/man/khard.conf.rst b/doc/source/man/khard.conf.rst index 3ebde70f..1835e5e5 100644 --- a/doc/source/man/khard.conf.rst +++ b/doc/source/man/khard.conf.rst @@ -50,7 +50,7 @@ general This section allows one to configure some general features about khard. The following keys are available in this section: - - *debug*: a boolean indication weather the logging level should be set to + - *debug*: a boolean indication whether the logging level should be set to *debug* by default (same effect as the :option:`--debug` option on the command line) - *default_action*: the default action/subcommand to use if the first non @@ -66,22 +66,23 @@ contact table - *display*: which part of the name to use in listings; this can be one of ``first_name``, ``last_name`` or ``formatted_name`` - - *group_by_addressbook*: weather or not to group contacts by address book in + - *group_by_addressbook*: whether or not to group contacts by address book in listings - - *localize_dates*: weather to localize dates or to use ISO date formats + - *localize_dates*: whether to localize dates or to use ISO date formats - *preferred_email_address_type*: labels of email addresses to prefer - *preferred_phone_number_type*: labels of telephone numbers to prefer - - *reverse*: weather to reverse the order of contact listings or not - - *show_nicknames*: weather to show nick names - - *show_uids*: weather to show uids + - *reverse*: whether to reverse the order of contact listings or not + - *show_nicknames*: whether to show nick names + - *show_uids*: whether to show uids + - *show_kinds*: whether to show kinds - *sort*: field by which to sort contact listings 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*: weather 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*: weather to skip unparsable vcards, otherwise khard exits + - *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 diff --git a/doc/source/man/khard.rst b/doc/source/man/khard.rst index 1ca60d6b..bc8e1ed2 100644 --- a/doc/source/man/khard.rst +++ b/doc/source/man/khard.rst @@ -14,8 +14,8 @@ Description ----------- :program:`khard` is an address book for the Unix command line. It can read, create, -modify and delete carddav address book entries. :program:`khard` only works with a local -store of VCARD files. It is intended to be used in conjunction with other +modify and delete vCard address book entries. :program:`khard` only works with a local +store of vCard files. It is intended to be used in conjunction with other programs like an email client, text editor, vdir synchronizer or VOIP client. Options @@ -78,8 +78,6 @@ These subcommands display detailed information about one subcommand. show display detailed information about one contact, supported output formats are "pretty", "yaml" and "vcard" -export - DEPRECATED, use ``show --format=yaml`` instead Modifying subcommands ~~~~~~~~~~~~~~~~~~~~~ @@ -102,8 +100,6 @@ move move a contact to a different addressbook remove remove a contact -source - DEPRECATED, use ``edit --format=vcard`` instead Other subcommands ~~~~~~~~~~~~~~~~~ diff --git a/doc/source/scripting.rst b/doc/source/scripting.rst index d2de1671..6fcb79fb 100644 --- a/doc/source/scripting.rst +++ b/doc/source/scripting.rst @@ -105,6 +105,14 @@ lines to your muttrc file: "khard add-email" \ "add the sender email address to khard" +If you want to search for email addresses in specific header fields, append the "--header" parameter: + +.. code-block:: + + macro index,pager A \ + "khard add-email --headers=from,cc --skip-already-added" \ + "add the sender and cc email addresses to khard" + Then navigate to an email message in mutt's index view and press "A" to start the address import dialog. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..b38c69a4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1670058159, + "narHash": "sha256-ERiP2JWanLuGV1PDyHTbcigFCfIi9oco5LFdMJHjREE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "49b8ad618e64d9fe9ab686817bfebe047860dcae", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..55a457c5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,16 @@ +{ + description = "Development flake for khard"; + + outputs = { self, nixpkgs }: { + + packages.x86_64-linux.default = + nixpkgs.legacyPackages.x86_64-linux.khard.overrideAttrs (oa: rec { + pname = "khard"; + name = "khard-${version}"; + version = "dev-${if self ? shortRev then self.shortRev else "dirty"}"; + SETUPTOOLS_SCM_PRETEND_VERSION = version; + src = ./.; + }); + + }; +} diff --git a/khard/actions.py b/khard/actions.py index 4dfdcbc9..0b00f0a1 100644 --- a/khard/actions.py +++ b/khard/actions.py @@ -14,7 +14,6 @@ class Actions: "birthdays": ["bdays"], "copy": ["cp"], "email": [], - "export": [], "filename": ["file"], "list": ["ls"], "merge": [], @@ -25,7 +24,6 @@ class Actions: "postaddress": ["post", "postaddr"], "remove": ["delete", "del", "rm"], "show": ["details"], - "source": ["src"], "template": [], } @@ -35,7 +33,7 @@ def get_action(cls, alias: str) -> Optional[str]: asociated with the given alias, None is returned. :param alias: the alias to look up - :rturns: the name of the corresponding action or None + :returns: the name of the corresponding action or None """ for action, alias_list in cls.action_map.items(): @@ -44,7 +42,7 @@ def get_action(cls, alias: str) -> Optional[str]: return None @classmethod - def get_aliases(cls, action: str) -> Optional[List[str]]: + def get_aliases(cls, action: str) -> List[str]: """Find all aliases for the given action. If there is no such action, None is returned. @@ -52,7 +50,7 @@ def get_aliases(cls, action: str) -> Optional[List[str]]: :returns: the list of aliases corresponding to the action or None """ - return cls.action_map.get(action) + return cls.action_map[action] @classmethod def get_actions(cls) -> Iterable[str]: diff --git a/khard/address_book.py b/khard/address_book.py index 6a1e0672..e3ff208b 100644 --- a/khard/address_book.py +++ b/khard/address_book.py @@ -5,7 +5,6 @@ import glob import logging import os -import re from typing import Dict, Generator, Iterator, List, Optional, Union import vobject.base @@ -196,8 +195,8 @@ def load(self, query: Query = AnyQuery(), self._private_objects, self._localize_dates) if card is None: continue - except (IOError, vobject.base.ParseError, binascii.Error) as err: - verb = "open" if isinstance(err, IOError) else "parse" + except (OSError, vobject.base.ParseError, binascii.Error) as err: + verb = "open" if isinstance(err, OSError) else "parse" logger.error("Error: Could not %s file %s\n%s", verb, filename, err) if self._skip: @@ -222,7 +221,7 @@ def load(self, query: Query = AnyQuery(), logger.warning( "%d of %d vCard files of address book %s could not be parsed.", errors, len(self.contacts) + errors, self) - logger.debug('Loded %s contacts from address book %s.', + logger.debug('Loaded %s contacts from address book %s.', len(self.contacts), self.name) @@ -265,7 +264,7 @@ def load(self, query: Query = AnyQuery()) -> None: else: self.contacts[uid] = abook.contacts[uid] self._loaded = True - logger.debug('Loded %s contacts from address book %s.', + logger.debug('Loaded %s contacts from address book %s.', len(self.contacts), self.name) def __getitem__(self, key: Union[int, str]) -> VdirAddressBook: diff --git a/khard/carddav_object.py b/khard/carddav_object.py index 4f4220fe..63ae0343 100644 --- a/khard/carddav_object.py +++ b/khard/carddav_object.py @@ -8,57 +8,30 @@ import copy import datetime +import io import locale import logging import os import re import sys import time -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union, Sequence from atomicwrites import atomic_write from ruamel import yaml +from ruamel.yaml import YAML import vobject -from . import address_book +from . import address_book # pylint: disable=unused-import # for type checking from . import helpers -from .object_type import ObjectType +from .helpers.typing import (convert_to_vcard, Date, ObjectType, StrList, + list_to_string, string_to_date, string_to_list) from .query import AnyQuery, Query logger = logging.getLogger(__name__) -def convert_to_vcard(name: str, value: Union[str, List[str]], - allowed_object_type: ObjectType) -> Union[str, List[str]]: - """converts user input into vcard compatible data structures - - :param name: object name, only required for error messages - :param value: user input - :param allowed_object_type: set the accepted return type for vcard - attribute - :returns: cleaned user input, ready for vcard or a ValueError - """ - if isinstance(value, str): - if allowed_object_type == ObjectType.list_with_strings: - return [value.strip()] - return value.strip() - if isinstance(value, list): - if allowed_object_type == ObjectType.string: - raise ValueError("Error: " + name + " must contain a string.") - if not all(isinstance(entry, str) for entry in value): - raise ValueError("Error: " + name + - " must not contain a nested list") - # filter out empty list items and strip leading and trailing space - return [x.strip() for x in value if x.strip()] - if allowed_object_type == ObjectType.string: - raise ValueError("Error: " + name + " must be a string.") - if allowed_object_type == ObjectType.list_with_strings: - raise ValueError("Error: " + name + " must be a list with strings.") - raise ValueError("Error: " + name + - " must be a string or a list with strings.") - - def multi_property_key(item: Union[str, Dict]) -> List: """key function to pass to sorted(), allowing sorting of dicts with lists and strings. Dicts will be sorted by their label, after other types. @@ -84,6 +57,7 @@ class VCardWrapper: by the vobject library are enforced here. """ + _default_kind = "individual" _default_version = "3.0" _supported_versions = ("3.0", "4.0") @@ -104,7 +78,6 @@ def __init__(self, vcard: vobject.vCard, version: Optional[str] = None :param vobject.vCard vcard: the vCard to wrap :param version: the version of the RFC to use (if the card has none) - :type version: str or None """ self.vcard = vcard if not self.version: @@ -115,7 +88,7 @@ def __init__(self, vcard: vobject.vCard, version: Optional[str] = None elif self.version not in self._supported_versions: logger.warning("Wrapping vCard with unsupported version %s, this " "might change any incompatible attributes.", - version) + self.version) def __str__(self) -> str: return self.formatted_name @@ -178,7 +151,7 @@ def _delete_vcard_object(self, name: str) -> None: self.vcard.remove(item) @staticmethod - def _parse_type_value(types: List[str], supported_types: List[str] + def _parse_type_value(types: Sequence[str], supported_types: Sequence[str] ) -> Tuple[List[str], List[str], int]: """Parse type value of phone numbers, email and post addresses. @@ -268,7 +241,7 @@ def version(self, value: str) -> None: # for version 4 but also makes sense for all other versions. self._delete_vcard_object("VERSION") version = self.vcard.add("version") - version.value = convert_to_vcard("version", value, ObjectType.string) + version.value = convert_to_vcard("version", value, ObjectType.str) @property def uid(self) -> str: @@ -280,7 +253,7 @@ def uid(self, value: str) -> None: # for version 4 but also makes sense for all other versions. self._delete_vcard_object("UID") uid = self.vcard.add('uid') - uid.value = convert_to_vcard("uid", value, ObjectType.string) + uid.value = convert_to_vcard("uid", value, ObjectType.str) def _update_revision(self) -> None: """Generate a new REV field for the vCard, replace any existing @@ -296,9 +269,9 @@ def _update_revision(self) -> None: rev.value = datetime.datetime.now().strftime("%Y%m%dT%H%M%SZ") @property - def birthday(self) -> Union[None, str, datetime.datetime]: + def birthday(self) -> Optional[Date]: """Return the birthday as a datetime object or a string depending on - weather it is of type text or not. If no birthday is present in the + whether it is of type text or not. If no birthday is present in the vcard None is returned. :returns: contacts birthday or None if not available @@ -311,13 +284,13 @@ def birthday(self) -> Union[None, str, datetime.datetime]: pass # else try to convert to a datetime object try: - return helpers.string_to_date(self.vcard.bday.value) + return string_to_date(self.vcard.bday.value) except (AttributeError, ValueError): pass return None @birthday.setter - def birthday(self, date: Union[str, datetime.datetime]) -> None: + def birthday(self, date: Date) -> None: """Store the given date as BDAY in the vcard. :param date: the new date to store as birthday @@ -332,7 +305,7 @@ def birthday(self, date: Union[str, datetime.datetime]) -> None: bday.params['VALUE'] = ['text'] @property - def anniversary(self) -> Union[None, str, datetime.datetime]: + def anniversary(self) -> Optional[Date]: """ :returns: contacts anniversary or None if not available """ @@ -344,17 +317,17 @@ def anniversary(self) -> Union[None, str, datetime.datetime]: pass # else try to convert to a datetime object try: - return helpers.string_to_date(self.vcard.anniversary.value) + return string_to_date(self.vcard.anniversary.value) except (AttributeError, ValueError): # vcard 3.0: x-anniversary (private object) try: - return helpers.string_to_date(self.vcard.x_anniversary.value) + return string_to_date(self.vcard.x_anniversary.value) except (AttributeError, ValueError): pass return None @anniversary.setter - def anniversary(self, date: Union[str, datetime.datetime]) -> None: + def anniversary(self, date: Date) -> None: value, text = self._prepare_birthday_value(date) if value is None: logger.warning('Failed to set anniversary to %s', date) @@ -411,7 +384,7 @@ def _get_new_group(self, group_type: str = "") -> str: def _add_labelled_object( self, obj_type: str, user_input, name_groups: bool = False, - allowed_object_type: ObjectType = ObjectType.string) -> None: + allowed_object_type: ObjectType = ObjectType.str) -> None: """Add an object to the VCARD. If user_input is a dict, the object will be added to a group with an ABLABEL created from the key of the dict. @@ -441,13 +414,12 @@ def _add_labelled_object( obj.value = convert_to_vcard(obj_type, user_input, allowed_object_type) - def _prepare_birthday_value(self, date: Union[str, datetime.datetime] - ) -> Tuple[Optional[str], bool]: + def _prepare_birthday_value(self, date: Date) -> Tuple[Optional[str], + bool]: """Prepare a value to be stored in a BDAY or ANNIVERSARY attribute. :param date: the date like value to be stored - :type date: datetime.datetime or str - :returns: the object to set as the .value for the attribute and weather + :returns: the object to set as the .value for the attribute and whether it should be stored as plain text :rtype: tuple(str,bool) """ @@ -477,6 +449,11 @@ def _prepare_birthday_value(self, date: Union[str, datetime.datetime] fmt = "%F" return date.strftime(fmt), False + @property + def kind(self) -> str: + kind = self._get_string_field("kind") or self._default_kind + return kind if kind != "org" else "organisation" + @property def formatted_name(self) -> str: return self._get_string_field("fn") @@ -486,7 +463,7 @@ def formatted_name(self, value: str) -> None: """Set the FN field to the new value. All previously existing FN fields are deleted. Version 4 of the specs - requires the vCard to only habe one FN field. For other versions we + requires the vCard to only have one FN field. For other versions we enforce this equally. :param str value: the new formatted name @@ -494,15 +471,13 @@ def formatted_name(self, value: str) -> None: """ self._delete_vcard_object("FN") if value: - final = convert_to_vcard("FN", value, ObjectType.string) + final = convert_to_vcard("FN", value, ObjectType.str) elif self._get_first_names() or self._get_last_names(): # autofill the FN field from the N field - names = [self._get_name_prefixes(), - self._get_first_names(), - self._get_last_names(), - self._get_name_suffixes()] + names = [self._get_name_prefixes(), self._get_first_names(), + self._get_last_names(), self._get_name_suffixes()] names = [x for x in names if x] - final = helpers.list_to_string(names, " ") + final = list_to_string(names, " ") else: # add an empty FN final = "" self.vcard.add("FN").value = final @@ -545,7 +520,7 @@ def get_first_name_last_name(self) -> str: names = self._get_first_names() + self._get_additional_names() + \ self._get_last_names() if names: - return helpers.list_to_string(names, " ") + return list_to_string(names, " ") return self.formatted_name def get_last_name_first_name(self) -> str: @@ -559,19 +534,25 @@ def get_last_name_first_name(self) -> str: self._get_additional_names() if last_names and first_and_additional_names: return "{}, {}".format( - helpers.list_to_string(last_names, " "), - helpers.list_to_string(first_and_additional_names, " ")) + list_to_string(last_names, " "), + list_to_string(first_and_additional_names, " ")) if last_names: - return helpers.list_to_string(last_names, " ") + return list_to_string(last_names, " ") if first_and_additional_names: - return helpers.list_to_string(first_and_additional_names, " ") + return list_to_string(first_and_additional_names, " ") return self.formatted_name - def _add_name(self, prefix: Union[str, List[str]], - first_name: Union[str, List[str]], - additional_name: Union[str, List[str]], - last_name: Union[str, List[str]], - suffix: Union[str, List[str]]) -> None: + @property + def first_name(self) -> str: + return list_to_string(self._get_first_names(), " ") + + @property + def last_name(self) -> str: + return list_to_string(self._get_last_names(), " ") + + def _add_name(self, prefix: StrList, first_name: StrList, + additional_name: StrList, last_name: StrList, + suffix: StrList) -> None: """Add an N entry to the vCard. No old entries are affected. :param prefix: @@ -581,14 +562,13 @@ def _add_name(self, prefix: Union[str, List[str]], :param suffix: """ name_obj = self.vcard.add('n') - stringlist = ObjectType.string_or_list_with_strings name_obj.value = vobject.vcard.Name( - prefix=convert_to_vcard("name prefix", prefix, stringlist), - given=convert_to_vcard("first name", first_name, stringlist), + prefix=convert_to_vcard("name prefix", prefix, ObjectType.both), + given=convert_to_vcard("first name", first_name, ObjectType.both), additional=convert_to_vcard("additional name", additional_name, - stringlist), - family=convert_to_vcard("last name", last_name, stringlist), - suffix=convert_to_vcard("name suffix", suffix, stringlist)) + ObjectType.both), + family=convert_to_vcard("last name", last_name, ObjectType.both), + suffix=convert_to_vcard("name suffix", suffix, ObjectType.both)) @property def organisations(self) -> List[Union[List[str], Dict[str, List[str]]]]: @@ -597,20 +577,19 @@ def organisations(self) -> List[Union[List[str], Dict[str, List[str]]]]: """ return self._get_multi_property("ORG") - def _add_organisation(self, organisation: Union[str, List[str]]) -> None: + def _add_organisation(self, organisation: StrList) -> None: """Add one ORG entry to the underlying vcard :param organisation: the value to add """ - self._add_labelled_object("org", organisation, True, - ObjectType.list_with_strings) + self._add_labelled_object("org", organisation, True, ObjectType.list) # check if fn attribute is already present if not self.vcard.getChildValue("fn") and self.organisations: # if not, set fn to organisation name first_org = self.organisations[0] if isinstance(first_org, dict): first_org = list(first_org.values())[0] - org_value = helpers.list_to_string(first_org, ", ") + org_value = list_to_string(first_org, ", ") self.formatted_name = org_value.replace("\n", " ").replace("\\", "") showas_obj = self.vcard.add('x-abshowas') @@ -670,7 +649,7 @@ def _add_category(self, categories: List[str]) -> None: """ categories_obj = self.vcard.add('categories') categories_obj.value = convert_to_vcard("category", categories, - ObjectType.list_with_strings) + ObjectType.list) @property def phone_numbers(self) -> Dict[str, List[str]]: @@ -681,7 +660,7 @@ def phone_numbers(self) -> Dict[str, List[str]]: for child in self.vcard.getChildren(): if child.name == "TEL": # phone types - type = helpers.list_to_string( + type = list_to_string( self._get_types_for_vcard_object(child, "voice"), ", ") if type not in phone_dict: phone_dict[type] = [] @@ -701,9 +680,9 @@ def phone_numbers(self) -> Dict[str, List[str]]: number_list.sort() return phone_dict - def _add_phone_number(self, type, number): + def _add_phone_number(self, type: str, number: str) -> None: standard_types, custom_types, pref = self._parse_type_value( - helpers.string_to_list(type, ","), self.phone_types_v4 if + string_to_list(type, ","), self.phone_types_v4 if self.version == "4.0" else self.phone_types_v3) if not standard_types and not custom_types and pref == 0: raise ValueError("Error: label for phone number " + number + @@ -711,17 +690,17 @@ def _add_phone_number(self, type, number): if len(custom_types) > 1: raise ValueError("Error: phone number " + number + " got more " "than one custom label: " + - helpers.list_to_string(custom_types, ", ")) + list_to_string(custom_types, ", ")) phone_obj = self.vcard.add('tel') if self.version == "4.0": phone_obj.value = "tel:{}".format( - convert_to_vcard("phone number", number, ObjectType.string)) + convert_to_vcard("phone number", number, ObjectType.str)) phone_obj.params['VALUE'] = ["uri"] if pref > 0: phone_obj.params['PREF'] = str(pref) else: phone_obj.value = convert_to_vcard("phone number", number, - ObjectType.string) + ObjectType.str) if pref > 0: standard_types.append("pref") if standard_types: @@ -746,7 +725,7 @@ def emails(self) -> Dict[str, List[str]]: email_dict: Dict[str, List[str]] = {} for child in self.vcard.getChildren(): if child.name == "EMAIL": - type = helpers.list_to_string( + type = list_to_string( self._get_types_for_vcard_object(child, "internet"), ", ") if type not in email_dict: email_dict[type] = [] @@ -756,9 +735,9 @@ def emails(self) -> Dict[str, List[str]]: email_list.sort() return email_dict - def add_email(self, type, address): + def add_email(self, type: str, address: str) -> None: standard_types, custom_types, pref = self._parse_type_value( - helpers.string_to_list(type, ","), self.email_types_v4 if + string_to_list(type, ","), self.email_types_v4 if self.version == "4.0" else self.email_types_v3) if not standard_types and not custom_types and pref == 0: raise ValueError("Error: label for email address " + address + @@ -766,10 +745,10 @@ def add_email(self, type, address): if len(custom_types) > 1: raise ValueError("Error: email address " + address + " got more " "than one custom label: " + - helpers.list_to_string(custom_types, ", ")) + list_to_string(custom_types, ", ")) email_obj = self.vcard.add('email') email_obj.value = convert_to_vcard("email address", address, - ObjectType.string) + ObjectType.str) if self.version == "4.0": if pref > 0: email_obj.params['PREF'] = str(pref) @@ -798,7 +777,7 @@ def post_addresses(self) -> Dict[str, List[Dict[str, Union[List, str]]]]: post_adr_dict: Dict[str, List[Dict[str, Union[List, str]]]] = {} for child in self.vcard.getChildren(): if child.name == "ADR": - type = helpers.list_to_string(self._get_types_for_vcard_object( + type = list_to_string(self._get_types_for_vcard_object( child, "home"), ", ") if type not in post_adr_dict: post_adr_dict[type] = [] @@ -812,20 +791,27 @@ def post_addresses(self) -> Dict[str, List[Dict[str, Union[List, str]]]]: # sort post address lists for post_adr_list in post_adr_dict.values(): post_adr_list.sort(key=lambda x: ( - helpers.list_to_string(x['city'], " ").lower(), - helpers.list_to_string(x['street'], " ").lower())) + list_to_string(x['city'], " ").lower(), + list_to_string(x['street'], " ").lower())) return post_adr_dict def get_formatted_post_addresses(self) -> Dict[str, List[str]]: - list2str = helpers.list_to_string formatted_post_adr_dict: Dict[str, List[str]] = {} for type, post_adr_list in self.post_addresses.items(): formatted_post_adr_dict[type] = [] for post_adr in post_adr_list: - get = lambda name: list2str(post_adr.get(name, ""), " ") + get: Callable[[str], str] = lambda name: list_to_string( + post_adr.get(name, ""), " ") + + # remove empty fields to avoid empty lines + for x in list(post_adr.keys()): + if post_adr.get(x) == "": + del post_adr[x] + strings = [] if "street" in post_adr: - strings.append(list2str(post_adr.get("street", ""), "\n")) + strings.append(list_to_string( + post_adr.get("street", ""), "\n")) if "box" in post_adr and "extended" in post_adr: strings.append("{} {}".format(get("box"), get("extended"))) elif "box" in post_adr: @@ -851,32 +837,25 @@ def get_formatted_post_addresses(self) -> Dict[str, List[str]]: def _add_post_address(self, type, box, extended, street, code, city, region, country): standard_types, custom_types, pref = self._parse_type_value( - helpers.string_to_list(type, ","), - self.address_types_v4 if self.version == "4.0" else - self.address_types_v3) + string_to_list(type, ","), self.address_types_v4 + if self.version == "4.0" else self.address_types_v3) if not standard_types and not custom_types and pref == 0: raise ValueError("Error: label for post address " + street + " is missing.") if len(custom_types) > 1: raise ValueError("Error: post address " + street + " got more " "than one custom " "label: " + - helpers.list_to_string(custom_types, ", ")) + list_to_string(custom_types, ", ")) adr_obj = self.vcard.add('adr') adr_obj.value = vobject.vcard.Address( - box=convert_to_vcard("box address field", box, - ObjectType.string_or_list_with_strings), + box=convert_to_vcard("box address field", box, ObjectType.both), extended=convert_to_vcard("extended address field", extended, - ObjectType.string_or_list_with_strings), - street=convert_to_vcard("street", street, - ObjectType.string_or_list_with_strings), - code=convert_to_vcard("post code", code, - ObjectType.string_or_list_with_strings), - city=convert_to_vcard("city", city, - ObjectType.string_or_list_with_strings), - region=convert_to_vcard("region", region, - ObjectType.string_or_list_with_strings), - country=convert_to_vcard("country", country, - ObjectType.string_or_list_with_strings)) + ObjectType.both), + street=convert_to_vcard("street", street, ObjectType.both), + code=convert_to_vcard("post code", code, ObjectType.both), + city=convert_to_vcard("city", city, ObjectType.both), + region=convert_to_vcard("region", region, ObjectType.both), + country=convert_to_vcard("country", country, ObjectType.both)) if self.version == "4.0": if pref > 0: adr_obj.params['PREF'] = str(pref) @@ -954,8 +933,7 @@ def get_formatted_birthday(self) -> str: ####################### @staticmethod - def _format_date_object(date: Union[None, str, datetime.datetime], - localize: bool) -> str: + def _format_date_object(date: Optional[Date], localize: bool) -> str: if not date: return "" if isinstance(date, str): @@ -994,11 +972,12 @@ def _parse_yaml(input: str) -> Dict: :returns: the parsed datastructure :rtype: dict """ - yaml_parser = yaml.YAML(typ='base') + yaml_parser = YAML(typ='base') # parse user input string try: contact_data = yaml_parser.load(input) - except (yaml.parser.ParserError, yaml.scanner.ScannerError) as err: + except (yaml.parser.ParserError, yaml.scanner.ScannerError, + yaml.constructor.DuplicateKeyError) as err: raise ValueError(err) else: if not contact_data: @@ -1054,7 +1033,7 @@ def _set_date(self, target: str, key: str, data: Dict) -> None: "vcard version 4.0. You may use 1900 as placeholder, if " "the year is unknown.".format(key)) try: - v2 = helpers.string_to_date(new) + v2 = string_to_date(new) if v2: setattr(self, target, v2) return @@ -1266,180 +1245,52 @@ def to_yaml(self) -> str: :returns: a YAML representation of this contact """ - strings = [] - for line in helpers.get_new_contact_template().splitlines(): - if line.startswith("#"): - strings.append(line) - elif line == "": - strings.append(line) - - elif line.lower().startswith("formatted name"): - strings += helpers.convert_to_yaml( - "Formatted name", self.formatted_name, 0, 15, True) - elif line.lower().startswith("prefix"): - strings += helpers.convert_to_yaml( - "Prefix", self._get_name_prefixes(), 0, 11, True) - elif line.lower().startswith("first name"): - strings += helpers.convert_to_yaml( - "First name", self._get_first_names(), 0, 11, True) - elif line.lower().startswith("additional"): - strings += helpers.convert_to_yaml( - "Additional", self._get_additional_names(), 0, 11, True) - elif line.lower().startswith("last name"): - strings += helpers.convert_to_yaml( - "Last name", self._get_last_names(), 0, 11, True) - elif line.lower().startswith("suffix"): - strings += helpers.convert_to_yaml( - "Suffix", self._get_name_suffixes(), 0, 11, True) - elif line.lower().startswith("nickname"): - strings += helpers.convert_to_yaml( - "Nickname", self.nicknames, 0, 9, True) - - elif line.lower().startswith("organisation"): - strings += helpers.convert_to_yaml( - "Organisation", self.organisations, 0, 13, True) - elif line.lower().startswith("title"): - strings += helpers.convert_to_yaml( - "Title", self.titles, 0, 6, True) - elif line.lower().startswith("role"): - strings += helpers.convert_to_yaml( - "Role", self.roles, 0, 6, True) - elif line.lower().startswith("phone"): - strings.append("Phone :") - if not self.phone_numbers: - strings.append(" cell : ") - strings.append(" home : ") - else: - longest_key = max(self.phone_numbers.keys(), key=len) - for type, number_list in sorted( - self.phone_numbers.items(), - key=lambda k: k[0].lower()): - strings += helpers.convert_to_yaml( - type, number_list, 4, len(longest_key) + 1, True) - - elif line.lower().startswith("email"): - strings.append("Email :") - if not self.emails: - strings.append(" home : ") - strings.append(" work : ") - else: - longest_key = max(self.emails.keys(), key=len) - for type, email_list in sorted(self.emails.items(), - key=lambda k: k[0].lower()): - strings += helpers.convert_to_yaml( - type, email_list, 4, len(longest_key) + 1, True) - - elif line.lower().startswith("address"): - strings.append("Address :") - if not self.post_addresses: - strings.append(" home :") - strings.append(" Box : ") - strings.append(" Extended : ") - strings.append(" Street : ") - strings.append(" Code : ") - strings.append(" City : ") - strings.append(" Region : ") - strings.append(" Country : ") - else: - for type, post_adr_list in sorted( - self.post_addresses.items(), - key=lambda k: k[0].lower()): - strings.append(" {}:".format(type)) - for post_adr in post_adr_list: - indentation = 8 - if len(post_adr_list) > 1: - indentation += 4 - strings.append(" -") - strings += helpers.convert_to_yaml( - "Box", post_adr.get("box"), indentation, 9, - True) - strings += helpers.convert_to_yaml( - "Extended", post_adr.get("extended"), - indentation, 9, True) - strings += helpers.convert_to_yaml( - "Street", post_adr.get("street"), indentation, - 9, True) - strings += helpers.convert_to_yaml( - "Code", post_adr.get("code"), indentation, 9, - True) - strings += helpers.convert_to_yaml( - "City", post_adr.get("city"), indentation, 9, - True) - strings += helpers.convert_to_yaml( - "Region", post_adr.get("region"), indentation, - 9, True) - strings += helpers.convert_to_yaml( - "Country", post_adr.get("country"), - indentation, 9, True) - - elif line.lower().startswith("private"): - strings.append("Private :") - if self.supported_private_objects: - longest_key = max(self.supported_private_objects, key=len) - for object in self.supported_private_objects: - strings += helpers.convert_to_yaml( - object, - self._get_private_objects().get(object, ""), 4, - len(longest_key) + 1, True) - - elif line.lower().startswith("anniversary"): - anniversary = self.anniversary - if anniversary: - if isinstance(anniversary, str): - strings.append("Anniversary : text= {}".format( - anniversary)) - elif (anniversary.year == 1900 and anniversary.month != 0 - and anniversary.day != 0 and anniversary.hour == 0 - and anniversary.minute == 0 - and anniversary.second == 0 - and self.version == "4.0"): - strings.append( - anniversary.strftime("Anniversary : --%m-%d")) - else: - tz = anniversary.tzname() - if ((tz and tz[3:]) or anniversary.hour != 0 - or anniversary.minute != 0 - or anniversary.second != 0): - strings.append("Anniversary : {}".format( - anniversary.isoformat())) - else: - strings.append( - anniversary.strftime("Anniversary : %F")) - else: - strings.append("Anniversary : ") - elif line.lower().startswith("birthday"): - birthday = self.birthday - if birthday: - if isinstance(birthday, str): - strings.append("Birthday : text= {}".format(birthday)) - elif birthday.year == 1900 and birthday.month != 0 and \ - birthday.day != 0 and birthday.hour == 0 and \ - birthday.minute == 0 and birthday.second == 0 and \ - self.version == "4.0": - strings.append(birthday.strftime("Birthday : --%m-%d")) - else: - tz = birthday.tzname() - if (tz and tz[3:] or birthday.hour != 0 - or birthday.minute != 0 - or birthday.second != 0): - strings.append( - "Birthday : {}".format(birthday.isoformat())) - else: - strings.append(birthday.strftime("Birthday : %F")) - else: - strings.append("Birthday : ") - elif line.lower().startswith("categories"): - strings += helpers.convert_to_yaml( - "Categories", self.categories, 0, 11, True) - elif line.lower().startswith("note"): - strings += helpers.convert_to_yaml( - "Note", self.notes, 0, 5, True) - elif line.lower().startswith("webpage"): - strings += helpers.convert_to_yaml( - "Webpage", self.webpages, 0, 8, True) + translation_table = { + "Formatted name": self.formatted_name, + "Prefix": self._get_name_prefixes(), + "First name": self._get_first_names(), + "Additional": self._get_additional_names(), + "Last name": self._get_last_names(), + "Suffix": self._get_name_suffixes(), + "Nickname": self.nicknames, + "Organisation": self.organisations, + "Title": self.titles, + "Role": self.roles, + "Phone": helpers.yaml_dicts( + self.phone_numbers, defaults=["cell", "home"]), + "Email": helpers.yaml_dicts( + self.emails, defaults=["home", "work"]), + "Categories": self.categories, + "Note": self.notes, + "Webpage": self.webpages, + "Anniversary": + helpers.yaml_anniversary(self.anniversary, self.version), + "Birthday": + helpers.yaml_anniversary(self.birthday, self.version), + "Address": helpers.yaml_addresses( + self.post_addresses, ["Box", "Extended", "Street", "Code", + "City", "Region", "Country"], defaults=["home"]) + } + template = helpers.get_new_contact_template() + yaml = YAML() + yaml.indent(mapping=4, sequence=4, offset=2) + template_obj = yaml.load(template) + for key in template_obj: + value = translation_table.get(key, None) + template_obj[key] = helpers.yaml_clean(value) + + if self.supported_private_objects: + template_obj["Private"] = helpers.yaml_clean( + helpers.yaml_dicts( + self._get_private_objects(), + self.supported_private_objects + )) + + stream = io.StringIO() + yaml.dump(template_obj, stream) # posix standard: eof char must be \n - return '\n'.join(strings) + "\n" + return stream.getvalue() + "\n" class CarddavObject(YAMLEditable): @@ -1566,8 +1417,7 @@ def pretty(self, verbose: bool = True) -> str: names = self._get_name_prefixes() + self._get_first_names() + \ self._get_additional_names() + self._get_last_names() + \ self._get_name_suffixes() - strings.append("Full name: {}".format( - helpers.list_to_string(names, " "))) + strings.append("Full name: {}".format(list_to_string(names, " "))) # organisation if self.organisations: strings += helpers.convert_to_yaml( @@ -1577,6 +1427,10 @@ def pretty(self, verbose: bool = True) -> str: if verbose: strings.append("Address book: {}".format(self.address_book)) + # kind + if self.kind is not None: + strings.append("Kind: {}".format(self.kind)) + # person related information if (self.birthday is not None or self.anniversary is not None or self.nicknames or self.roles or self.titles): @@ -1665,7 +1519,7 @@ def write_to_file(self, overwrite: bool = False) -> None: def delete_vcard_file(self) -> None: try: os.remove(self.filename) - except IOError as err: + except OSError as err: logger.error("Can not remove vCard file: %s", err) @classmethod diff --git a/khard/cli.py b/khard/cli.py index 35a82994..e8dcc4ca 100644 --- a/khard/cli.py +++ b/khard/cli.py @@ -8,43 +8,44 @@ from .actions import Actions from .carddav_object import CarddavObject from .config import Config, ConfigError -from .query import AndQuery, AnyQuery, FieldQuery, NameQuery, TermQuery, parse +from .query import AndQuery, AnyQuery, FieldQuery, NameQuery, parse from .version import version as khard_version logger = logging.getLogger(__name__) -def field_argument(orignal: str) -> List[str]: - """Ensure the fields specified for `ls -F` are proper field names. - Nested attribute names are not checked. +class FieldsArgument: + """A factory to create callable objects for add_argument's type= parameter. - :param orignal: the value from the command line - :returns: the orignal value split at "," if the fields are spelled correctly - :throws: argparse.ArgumentTypeError + The object can parse comma seperated strings into list of strings, and can + also check if the single elements are spelled correctly. """ - special_fields = ['index', 'name', 'phone', 'email'] - choices = sorted(special_fields + CarddavObject.get_properties()) - ret = [] - for candidate in orignal.split(','): - candidate = candidate.lower() - field = candidate.split('.')[0] - if field in choices: - ret.append(candidate) - else: - raise argparse.ArgumentTypeError( - '"{}" is not an accepted field. Accepted fields are {}.'.format( - field, ', '.join('"{}"'.format(c) for c in choices))) - return ret - - -def comma_separated_argument(original: str) -> List[str]: - """Return the original string split by commas - - :param original: the value from the command line - :returns: the original value split at "," and lower cased - """ - return [f.lower() for f in original.split(",")] + + 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 + a dot) must match on of the choices + """ + self._choices = sorted(choices) + self._nested = nested + + def __call__(self, argument: str) -> List[str]: + ret = [] + for candidate in argument.split(","): + candidate = candidate.lower() + test = candidate.split('.')[0] if self._nested else candidate + if test in self._choices: + ret.append(candidate) + else: + choices = ', '.join('"{}"'.format(c) for c in self._choices) + raise argparse.ArgumentTypeError( + '"{}" is not an accepted field. Accepted fields are {}.' + .format(test, choices)) + return ret def create_parsers() -> Tuple[argparse.ArgumentParser, @@ -57,7 +58,6 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, further options and arguments. :returns: the two parsers for the first and the second parsing pass - :rtype: (argparse.ArgumentParser, argparse.ArgumentParser) """ # Create the base argument parser. It will be reused for the first and # second round of argument parsing. @@ -190,7 +190,7 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, "contact") # create subparsers for actions - subparsers = parser.add_subparsers(dest="action") + subparsers = parser.add_subparsers(dest="action", metavar="SUBCOMMAND") list_parser = subparsers.add_parser( "list", aliases=Actions.get_aliases("list"), @@ -201,9 +201,13 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, list_parser.add_argument( "-p", "--parsable", action="store_true", help="Machine readable format: uid\\tcontact_name\\taddress_book_name") + field_argument = FieldsArgument('index', 'name', 'phone', 'email', + *CarddavObject.get_properties(), + nested=True) list_parser.add_argument( "-F", "--fields", default=[], type=field_argument, - help="Comma separated list of fields to show") + help="Comma separated list of fields to show " + "(use -F help for a list of top level fields)") show_parser = subparsers.add_parser( "show", aliases=Actions.get_aliases("show"), @@ -288,13 +292,13 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, "--vcard-version", choices=("3.0", "4.0"), dest='preferred_version', help="Select preferred vcard version for new contact") add_email_parser.add_argument( - "-H", - "--headers", - dest='fields', - default=["from"], - type=comma_separated_argument, - help="Extract contacts from the given comma separated header fields. \ - `all` searches all headers.") + "-H", "--headers", default=["from"], + type=lambda x: [y.lower() for y in x.split(",")], + help="Extract contacts from the given comma separated header fields. " + "`all` searches all headers.") + add_email_parser.add_argument( + "--skip-already-added", action="store_true", + help="Skip already added email addresses") subparsers.add_parser( "merge", aliases=Actions.get_aliases("merge"), @@ -348,21 +352,6 @@ def create_parsers() -> Tuple[argparse.ArgumentParser, description="list filenames of all matching contacts", help="list filenames of all matching contacts") - # Deprecated subcommands: They can be removed after the next release - # (v0.17) - export_parser = subparsers.add_parser( - "export", aliases=Actions.get_aliases("export"), parents=[ - default_addressbook_parser, default_search_parser, sort_parser], - description="DEPRECATED use 'show --format=yaml'", - help="DEPRECATED use 'show --format=yaml'") - export_parser.add_argument("-o", "--output-file", default=sys.stdout, - type=argparse.FileType("w")) - subparsers.add_parser( - "source", aliases=Actions.get_aliases("source"), parents=[ - default_addressbook_parser, default_search_parser, sort_parser], - description="DEPRECATED use 'edit --format=vcard'", - help="DEPRECATED use 'edit --format=vcard'") - # Replace the print_help method of the first parser with the print_help # method of the main parser. This makes it possible to have the first # parser handle the help option so that command line help can be printed @@ -400,6 +389,8 @@ def parse_args(argv: List[str]) -> Tuple[argparse.Namespace, Config]: config = Config(args.config) except ConfigError as err: parser.exit(3, "Error in config file: {}\n".format(err)) + except OSError as err: + parser.exit(3, "Error reading config file: {}\n".format(err)) logger.debug("Finished parsing config=%s", vars(config)) # Check the log level again and merge the value from the command line with @@ -471,16 +462,6 @@ def parse_args(argv: List[str]) -> Tuple[argparse.Namespace, Config]: if "target_uid" in args: del args.target_uid - # Normalize all deprecated subcommands and emit warnings. - if args.action == "export": - logger.error("Deprecated subcommand: use 'show --format=yaml'.") - args.action = "show" - args.format = "yaml" - elif args.action == "source": - logger.error("Deprecated subcommand: use 'edit --format=vcard'.") - args.action = "edit" - args.format = "vcard" - return args, config @@ -518,4 +499,7 @@ def init(argv: List[str]) -> Tuple[argparse.Namespace, Config]: # example: "ls" --> "list" args.action = Actions.get_action(args.action) - return args, merge_args_into_config(args, conf) + try: + return args, merge_args_into_config(args, conf) + except ConfigError as err: + sys.exit(str(err)) diff --git a/khard/config.py b/khard/config.py index 1843438c..4af36d9f 100644 --- a/khard/config.py +++ b/khard/config.py @@ -9,7 +9,11 @@ from typing import Iterable, Dict, List, Optional, Union import configobj -import validate +try: + # since configobj 5.1 + from configobj import validate +except ImportError: + import validate from .actions import Actions from .address_book import AddressBookCollection, AddressBookNameError, \ @@ -136,7 +140,9 @@ def _set_attributes(self) -> None: """Set the attributes from the internal config instance on self.""" general = self.config["general"] self.debug = general["debug"] - self.editor = general["editor"] or os.environ.get("EDITOR", "vim") + self.editor = ( + general["editor"] or shlex.split(os.environ.get("EDITOR", "vim")) + ) self.merge_editor = general["merge_editor"] \ or os.environ.get("MERGE_EDITOR", "vimdiff") self.default_action = general["default_action"] @@ -157,6 +163,7 @@ def _set_attributes(self) -> None: self.preferred_email_address_type = table['preferred_email_address_type'] self.preferred_phone_number_type = table['preferred_phone_number_type'] self.show_uids = table['show_uids'] + self.show_kinds = table['show_kinds'] def init_address_books(self) -> None: """Initialize the internal address book collection. @@ -173,7 +180,7 @@ def init_address_books(self) -> None: self.abooks = AddressBookCollection( "tmp", [VdirAddressBook(name, section[name]['path'], **kwargs) for name in section]) - except IOError as err: + except OSError as err: raise ConfigError(str(err)) def get_address_books(self, names: Iterable[str], queries: Dict[str, Query] diff --git a/khard/data/config.spec b/khard/data/config.spec index c7255070..a73230d9 100644 --- a/khard/data/config.spec +++ b/khard/data/config.spec @@ -13,6 +13,7 @@ preferred_phone_number_type = string_list(default=list('pref')) reverse = boolean(default=False) show_nicknames = boolean(default=False) show_uids = boolean(default=True) +show_kinds = boolean(default=False) sort = option('first_name', 'last_name', 'formatted_name', default='first_name') [vcard] diff --git a/khard/formatter.py b/khard/formatter.py index 760f63ea..06c64f7c 100644 --- a/khard/formatter.py +++ b/khard/formatter.py @@ -1,6 +1,6 @@ """Formatting and sorting of contacts""" -from typing import cast, Dict, List +from typing import Dict, List from .carddav_object import CarddavObject @@ -71,6 +71,8 @@ def get_special_field(self, vcard: CarddavObject, field: str) -> str: if vcard.emails: return self.format_labeled_field(vcard.emails, self._preferred_email) + if field == 'kind': + return vcard.kind return "" @staticmethod diff --git a/khard/helpers.py b/khard/helpers/__init__.py similarity index 60% rename from khard/helpers.py rename to khard/helpers/__init__.py index 8eb22563..e8a50f8a 100644 --- a/khard/helpers.py +++ b/khard/helpers/__init__.py @@ -1,11 +1,13 @@ """Some helper functions for khard""" -import os +from datetime import datetime import pathlib import random import string -from datetime import datetime -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union + +from ruamel.yaml.scalarstring import LiteralScalarString +from .typing import list_to_string def pretty_print(table: List[List[str]], justify: str = "L") -> str: @@ -56,57 +58,124 @@ def pretty_print(table: List[List[str]], justify: str = "L") -> str: return '\n'.join(table_row_list) -def list_to_string(input: Union[str, List], delimiter: str) -> str: - """converts list to string recursively so that nested lists are supported +def get_random_uid() -> str: + return ''.join([random.choice(string.ascii_lowercase + string.digits) + for _ in range(36)]) - :param input: a list of strings and lists of strings (and so on recursive) - :param delimiter: the deimiter to use when joining the items - :returns: the recursively joined list +def yaml_clean(value: Union[str, Sequence, Dict[str, Any], None] + ) -> Union[Sequence, str, Dict[str, Any], LiteralScalarString, + None]: """ - if isinstance(input, list): - return delimiter.join( - list_to_string(item, delimiter) for item in input) - return input + sanitize yaml values according to some simple principles: + 1. empty values are none, so ruamel does not print an empty list/str + 2. list with only one item become this item + 3. multiline strings use the YAML literal style: + https://yaml.org/spec/1.2.2/#literal-style + :param value: the value to be sanitized + :returns: the sanitized value + """ + # special case for empty values + if not value: + return None -def string_to_list(input: Union[str, List[str]], delimiter: str) -> List[str]: - if isinstance(input, list): - return input - return [x.strip() for x in input.split(delimiter)] + if isinstance(value, list): + # special case for single item lists: + if len(value) == 1 and isinstance(value[0], str): + return value[0] + elif isinstance(value, str): + if "\n" in value: + return LiteralScalarString(value) + return value -def string_to_date(string: str) -> datetime: - """Convert a date string into a date object. - :param string: the date string to parse - :returns: the parsed datetime object +def yaml_dicts( + data: Optional[Dict[str, Any]], + defaults: Union[Dict[str, Any], List[str], None] = None + ) -> Optional[Dict[str, Any]]: """ - # try date formats --mmdd, --mm-dd, yyyymmdd, yyyy-mm-dd and datetime - # formats yyyymmddThhmmss, yyyy-mm-ddThh:mm:ss, yyyymmddThhmmssZ, - # yyyy-mm-ddThh:mm:ssZ. - for fmt in ("--%m%d", "--%m-%d", "%Y%m%d", "%Y-%m-%d", "%Y%m%dT%H%M%S", - "%Y-%m-%dT%H:%M:%S", "%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ"): - try: - return datetime.strptime(string, fmt) - except ValueError: - continue # with the next format - # try datetime formats yyyymmddThhmmsstz and yyyy-mm-ddThh:mm:sstz where tz - # may look like -06:00. - for fmt in ("%Y%m%dT%H%M%S%z", "%Y-%m-%dT%H:%M:%S%z"): - try: - return datetime.strptime(''.join(string.rsplit(":", 1)), fmt) - except ValueError: - continue # with the next format - raise ValueError + format a dict according to template, if empty use specified defaults + :param data: dict of contact data with keys as types and values as values. + :param defaults: default dict to be used if data is empty. + :returns: dict of types and values. + """ + if not data: + if isinstance(defaults, list): + return {key: None for key in defaults} -def get_random_uid() -> str: - return ''.join([random.choice(string.ascii_lowercase + string.digits) - for _ in range(36)]) + return defaults + + data_dict = {} + for key, value_list in sorted(data.items(), key=lambda k: k[0].lower()): + data_dict[key] = value_list[0] if len(value_list) == 1 else value_list + + return data_dict + + +def yaml_addresses(addresses: Optional[Dict[str, Any]], + address_properties: List[str], + defaults: Optional[List[str]] = None + ) -> Optional[Dict[str, Any]]: + """ + build a dict from an address, using a list of properties, an address has. + + :param addresses: dict of addresses with existing properties + :param address_properties: list of properties that make up an address + :param defaults: list of address types + :returns: dict of address types with all properties + """ + if not addresses: + if not defaults: + return None + + 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 + } + 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. + + :param anniversary: a string or a datetime object, that is the anniversary + :param version: the VCard version to format for + :returns: the formatted date string + """ + if not anniversary: + return None + + if isinstance(anniversary, datetime): + if (version == "4.0" and anniversary.year == 1900 + and anniversary.month != 0 + and anniversary.day != 0 + and anniversary.hour == 0 + and anniversary.minute == 0 + and anniversary.second == 0): + return anniversary.strftime("--%m-%d") + + time_zone = anniversary.tzname() + if ((time_zone and time_zone[3:]) + or anniversary.hour != 0 + or anniversary.minute != 0 + or anniversary.second != 0): + return anniversary.isoformat() + return anniversary.strftime("%F") -def file_modification_date(filename: str) -> datetime: - return datetime.fromtimestamp(os.path.getmtime(filename)) + # default case: anniversary is a string + return anniversary def convert_to_yaml(name: str, value: Union[None, str, List], indentation: int, @@ -197,6 +266,6 @@ def get_new_contact_template( for object in supported_private_objects: formatted_private_objects += convert_to_yaml( object, "", 12, len(longest_key)+1, True) - template = pathlib.Path(__file__).parent / 'data' / 'template.yaml' + template = pathlib.Path(__file__).parent.parent / 'data' / 'template.yaml' with template.open() as temp: return temp.read().format('\n'.join(formatted_private_objects)) diff --git a/khard/helpers/interactive.py b/khard/helpers/interactive.py new file mode 100644 index 00000000..ff372efc --- /dev/null +++ b/khard/helpers/interactive.py @@ -0,0 +1,157 @@ +"""Helper functions for user interaction.""" + +import contextlib +from datetime import datetime +from enum import Enum +import os.path +import subprocess +from tempfile import NamedTemporaryFile +from typing import Callable, Generator, List, Optional, TypeVar, Union + +from ..carddav_object import CarddavObject + + +T = TypeVar("T") + + +def confirm(message: str, accept_enter_key: bool = True) -> bool: + """Ask the user for confirmation on the terminal. + + :param message: the question to print + :param accept_enter_key: Accept ENTER as alternative for "n" + :returns: the answer of the user + """ + 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]: + """Ask the user to select an item from a list. + + The list should be displayed to the user before calling this function and + should be indexed starting with 1. + + :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 + """ + while True: + try: + answer = input("Enter Index ({}q to quit): ".format( + "0 for None, " if include_none else "")) + answer = answer.lower() + if answer in ["", "q"]: + print("Canceled") + return None + index = int(answer) + if include_none and index == 0: + return None + if index > 0: + return items[index - 1] + except (EOFError, IndexError, ValueError): + pass + print("Please enter an index value between 1 and {} or q to quit." + .format(len(items))) + + +class EditState(Enum): + modified = 1 + unmodified = 2 + aborted = 3 + + +class Editor: + + """Wrapper around subprocess.Popen to edit and merge files.""" + + def __init__(self, editor: Union[str, List[str]], + merge_editor: Union[str, List[str]]) -> None: + self.editor = [editor] if isinstance(editor, str) else editor + self.merge_editor = [merge_editor] if isinstance(merge_editor, str) \ + else merge_editor + + @staticmethod + @contextlib.contextmanager + def write_temp_file(text: str = "") -> Generator[str, None, None]: + """Create a new temporary file and write some initial text to it. + + :param text: the text to write to the temp file + :returns: the file name of the newly created temp file + """ + with NamedTemporaryFile(mode='w+t', suffix='.yml') as tmp: + tmp.write(text) + tmp.flush() + yield tmp.name + + @staticmethod + def _mtime(filename: str) -> datetime: + return datetime.fromtimestamp(os.path.getmtime(filename)) + + def edit_files(self, file1: str, file2: Optional[str] = None) -> EditState: + """Edit the given files + + If only one file is given the timestamp of this file is checked, if two + files are given the timestamp of the second file is checked for + modification. + + :param file1: the first file (checked for modification if file2 not + present) + :param file2: the second file (checked for modification of present) + :returns: the result of the modification + """ + if file2 is None: + command = self.editor + [file1] + else: + command = self.merge_editor + [file1, file2] + timestamp = self._mtime(command[-1]) + child = subprocess.Popen(command) + child.communicate() + if child.returncode != 0: + return EditState.aborted + if timestamp == self._mtime(command[-1]): + return EditState.unmodified + return EditState.modified + + def edit_templates(self, yaml2card: Callable[[str], CarddavObject], + template1: str, template2: Optional[str] = None + ) -> Optional[CarddavObject]: + """Edit YAML templates of contacts and parse them back + + :param yaml2card: a function to convert the modified YAML templates + into a CarddavObject + :param template1: the first template + :param template2: the second template (optional, for merges) + :returns: the parsed CarddavObject or None + """ + templates = [t for t in (template1, template2) if t is not None] + 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 + # aborts + while True: + if self.edit_files(*files) == EditState.unmodified: + return None + # read temp file contents after editing + with open(files[-1], "r") as tmp: + modified_template = tmp.read() + # No actual modification was done + if modified_template == templates[-1]: + return None + # try to create contact from user input + try: + return yaml2card(modified_template) + except ValueError as err: + print("\n{}\n".format(err)) + if not confirm("Do you want to open the editor again?"): + print("Canceled") + return None + return None # only for mypy diff --git a/khard/helpers/typing.py b/khard/helpers/typing.py new file mode 100644 index 00000000..e22c570b --- /dev/null +++ b/khard/helpers/typing.py @@ -0,0 +1,89 @@ +"""Helper code for type annotations and runtime type conversion.""" + +from datetime import datetime +from enum import Enum +from typing import List, Union + + +class ObjectType(Enum): + str = 1 + list = 2 + both = 3 + + +# some type aliases +Date = Union[str, datetime] +StrList = Union[str, List[str]] + + +def convert_to_vcard(name: str, value: StrList, constraint: ObjectType + ) -> StrList: + """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 + """ + if isinstance(value, str): + if constraint == ObjectType.list: + return [value.strip()] + return value.strip() + if isinstance(value, list): + if constraint == ObjectType.str: + raise ValueError("Error: " + name + " must contain a string.") + if not all(isinstance(entry, str) for entry in value): + raise ValueError("Error: " + name + + " must not contain a nested list") + # filter out empty list items and strip leading and trailing space + return [x.strip() for x in value if x.strip()] + if constraint == ObjectType.str: + raise ValueError("Error: " + name + " must be a string.") + if constraint == ObjectType.list: + raise ValueError("Error: " + name + " must be a list with strings.") + raise ValueError("Error: " + name + + " must be a string or a list with strings.") + + +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 + :returns: the recursively joined list + """ + if isinstance(input, list): + return delimiter.join( + list_to_string(item, delimiter) for item in input) + return input + + +def string_to_list(input: Union[str, List[str]], delimiter: str) -> List[str]: + if isinstance(input, list): + return input + return [x.strip() for x in input.split(delimiter)] + + +def string_to_date(string: str) -> datetime: + """Convert a date string into a date object. + + :param string: the date string to parse + :returns: the parsed datetime object + """ + # try date formats --mmdd, --mm-dd, yyyymmdd, yyyy-mm-dd and datetime + # formats yyyymmddThhmmss, yyyy-mm-ddThh:mm:ss, yyyymmddThhmmssZ, + # yyyy-mm-ddThh:mm:ssZ. + for fmt in ("--%m%d", "--%m-%d", "%Y%m%d", "%Y-%m-%d", "%Y%m%dT%H%M%S", + "%Y-%m-%dT%H:%M:%S", "%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ"): + try: + return datetime.strptime(string, fmt) + except ValueError: + continue # with the next format + # try datetime formats yyyymmddThhmmsstz and yyyy-mm-ddThh:mm:sstz where tz + # may look like -06:00. + for fmt in ("%Y%m%dT%H%M%S%z", "%Y-%m-%dT%H:%M:%S%z"): + try: + return datetime.strptime(''.join(string.rsplit(":", 1)), fmt) + except ValueError: + continue # with the next format + raise ValueError diff --git a/khard/khard.py b/khard/khard.py index 9561cb9c..2a488108 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -6,11 +6,11 @@ from email.policy import SMTP as SMTP_POLICY from email.headerregistry import Address, AddressHeader, Group import logging +import operator import os -import subprocess import sys -from tempfile import NamedTemporaryFile -from typing import cast, Dict, Iterable, List, Optional, TypeVar, Union +import textwrap +from typing import cast, Callable, Dict, Iterable, List, Optional, Union from unidecode import unidecode @@ -21,119 +21,43 @@ from . import cli from .config import Config from .formatter import Formatter -from .query import AndQuery, AnyQuery, NameQuery, OrQuery, Query, TermQuery +from .helpers import interactive +from .helpers.interactive import confirm +from .query import AndQuery, AnyQuery, OrQuery, Query, TermQuery from .version import version as khard_version logger = logging.getLogger(__name__) config: Config -T = TypeVar("T") -def confirm(message: str) -> bool: - """Ask the user for confirmation on the terminal. - - :param message: the question to print - :returns: the answer of the user - """ - while True: - answer = input(message + ' (y/N) ') - answer = answer.lower() - if answer == 'y': - return True - if answer in ['', 'n', 'q']: +def version_check(contact: CarddavObject, description: str) -> bool: + if contact.version not in config.supported_vcard_versions: + print("Warning:\nThe {} is based on vcard version {} but khard only " + "supports the modification of vcards with version 3.0 and 4.0.\n" + "If you proceed, the contact will be converted to vcard version " + "{} but beware: This could corrupt the contact file or cause " + "data loss.".format(description, contact.version, + config.preferred_vcard_version)) + if not confirm("Do you want to proceed anyway?"): + print("Canceled") return False - print('Please answer with "y" for yes or "n" for no.') - - -def select(items: List[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 - should be indexed starting with 1. This function might exit if the user - selects "q". - - :param items: the list from which to select - :param include_none: weather to allow the selection of no item - :returns: None or the selected item - """ - while True: - try: - answer = input("Enter Index ({}q to quit): ".format( - "0 for None, " if include_none else "")) - answer = answer.lower() - if answer in ["", "q"]: - print("Canceled") - return None - index = int(answer) - if include_none and index == 0: - return None - if index > 0: - return items[index - 1] - except (EOFError, IndexError, ValueError): - pass - print("Please enter an index value between 1 and {} or q to exit." - .format(len(items))) - - -def write_temp_file(text: str = "") -> str: - """Create a new temporary file and write some initial text to it. - - :param text: the text to write to the temp file - :returns: the file name of the newly created temp file - """ - with NamedTemporaryFile(mode='w+t', suffix='.yml', delete=False) as tmp: - tmp.write(text) - return tmp.name - - -def edit(*filenames: str, merge: bool = False) -> None: - """Edit the given files with the configured editor or merge editor""" - editor = config.merge_editor if merge else config.editor - editor = [editor] if isinstance(editor, str) else editor - editor.extend(filenames) - child = subprocess.Popen(editor) - child.communicate() + return True def create_new_contact(address_book: VdirAddressBook) -> None: + editor = interactive.Editor(config.editor, config.merge_editor) # create temp file template = "# create new contact\n# Address book: {}\n# Vcard version: " \ "{}\n# if you want to cancel, exit without saving\n\n{}".format( address_book, config.preferred_vcard_version, helpers.get_new_contact_template(config.private_objects)) - temp_file_name = write_temp_file(template) - temp_file_creation = helpers.file_modification_date(temp_file_name) - - while True: - edit(temp_file_name) - if temp_file_creation == helpers.file_modification_date( - temp_file_name): - new_contact = None - os.remove(temp_file_name) - break - - # read temp file contents after editing - with open(temp_file_name, "r") as tmp: - new_contact_yaml = tmp.read() - - # try to create new contact - try: - new_contact = CarddavObject.from_yaml( - address_book, new_contact_yaml, config.private_objects, - config.preferred_vcard_version, config.localize_dates) - except ValueError as err: - print("\n{}\n".format(err)) - if not confirm("Do you want to open the editor again?"): - print("Canceled") - os.remove(temp_file_name) - sys.exit(0) - else: - os.remove(temp_file_name) - break + new_contact = editor.edit_templates(lambda t: CarddavObject.from_yaml( + address_book, t, config.private_objects, + config.preferred_vcard_version, config.localize_dates), template) # create carddav object from temp file - if new_contact is None or template == new_contact_yaml: + if new_contact is None: print("Canceled") else: new_contact.write_to_file() @@ -141,40 +65,15 @@ def create_new_contact(address_book: VdirAddressBook) -> None: def modify_existing_contact(old_contact: CarddavObject) -> None: + editor = interactive.Editor(config.editor, config.merge_editor) # create temp file and open it with the specified text editor - temp_file_name = write_temp_file( - "# Edit contact: {}\n# Address book: {}\n# Vcard version: {}\n" - "# if you want to cancel, exit without saving\n\n{}".format( - old_contact, old_contact.address_book, old_contact.version, - old_contact.to_yaml())) - - temp_file_creation = helpers.file_modification_date(temp_file_name) - - while True: - edit(temp_file_name) - if temp_file_creation == helpers.file_modification_date( - temp_file_name): - new_contact = None - os.remove(temp_file_name) - break - - # read temp file contents after editing - with open(temp_file_name, "r") as tmp: - new_contact_template = tmp.read() - - # try to create contact from user input - try: - new_contact = CarddavObject.clone_with_yaml_update( - old_contact, new_contact_template, config.localize_dates) - except ValueError as err: - print("\n{}\n".format(err)) - if not confirm("Do you want to open the editor again?"): - print("Canceled") - os.remove(temp_file_name) - sys.exit(0) - else: - os.remove(temp_file_name) - break + text = ("# Edit contact: {}\n# Address book: {}\n# Vcard version: {}\n" + "# if you want to cancel, exit without saving\n\n{}".format( + old_contact, old_contact.address_book, old_contact.version, + old_contact.to_yaml())) + new_contact = editor.edit_templates( + lambda t: CarddavObject.clone_with_yaml_update( + old_contact, t, config.localize_dates), text) # check if the user changed anything if new_contact is None or old_contact == new_contact: @@ -188,61 +87,21 @@ 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 - if target_contact.version not in config.supported_vcard_versions: - print("Warning:\nThe target contact in which to merge is based on " - "vcard version {} but khard only supports the modification of " - "vcards with version 3.0 and 4.0.\nIf you proceed, the contact " - "will be converted to vcard version {} but beware: This could " - "corrupt the contact file or cause data loss.".format( - target_contact.version, config.preferred_vcard_version)) - if not confirm("Do you want to proceed anyway?"): - print("Canceled") - sys.exit(0) + if not version_check(target_contact, "target contact in which to merge"): + return # create temp files for each vcard - # source vcard - source_temp_file_name = write_temp_file( - "# merge from {}\n# Address book: {}\n# Vcard version: {}\n" - "# if you want to cancel, exit without saving\n\n{}".format( - source_contact, source_contact.address_book, - source_contact.version, source_contact.to_yaml())) - # target vcard - target_temp_file_name = write_temp_file( - "# merge into {}\n# Address book: {}\n# Vcard version: {}\n" - "# if you want to cancel, exit without saving\n\n{}".format( - target_contact, target_contact.address_book, - target_contact.version, target_contact.to_yaml())) - - target_temp_file_creation = helpers.file_modification_date( - target_temp_file_name) - while True: - edit(source_temp_file_name, target_temp_file_name, merge=True) - if target_temp_file_creation == helpers.file_modification_date( - target_temp_file_name): - merged_contact = None - os.remove(source_temp_file_name) - os.remove(target_temp_file_name) - break - - # load target template contents - with open(target_temp_file_name, "r") as target_tf: - merged_contact_template = target_tf.read() - - # try to create contact from user input - try: - merged_contact = CarddavObject.clone_with_yaml_update( - target_contact, merged_contact_template, config.localize_dates) - except ValueError as err: - print("\n{}\n".format(err)) - if not confirm("Do you want to open the editor again?"): - print("Canceled") - os.remove(source_temp_file_name) - os.remove(target_temp_file_name) - return - else: - os.remove(source_temp_file_name) - os.remove(target_temp_file_name) - break - + 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( + source_contact, source_contact.address_book, + source_contact.version, source_contact.to_yaml())) + target_text = ("# merge into {}\n# Address book: {}\n# Vcard version: {}\n" + "# if you want to cancel, exit without saving\n\n{}".format( + target_contact, target_contact.address_book, + target_contact.version, target_contact.to_yaml())) + merged_contact = editor.edit_templates( + lambda t: CarddavObject.clone_with_yaml_update( + target_contact, t, config.localize_dates), src_text, target_text) # compare them if merged_contact is None or target_contact == merged_contact: print("Target contact unmodified\n\n{}".format( @@ -304,20 +163,24 @@ def list_address_books(address_books: Union[AddressBookCollection, def list_contacts(vcard_list: List[CarddavObject], fields: Iterable[str] = (), parsable: bool = False) -> None: selected_address_books: List[VdirAddressBook] = [] + selected_kinds = set() for contact in vcard_list: if contact.address_book not in selected_address_books: selected_address_books.append(contact.address_book) + if contact.kind not in selected_kinds: + selected_kinds.add(contact.kind) table = [] - # table header - if len(selected_address_books) == 1: - if not parsable: - print("Address book: {}".format(selected_address_books[0])) - table_header = ["index", "name", "phone", "email"] - else: - if not parsable: - print("Address books: {}".format(', '.join( - [str(book) for book in selected_address_books]))) - table_header = ["index", "name", "phone", "email", "address_book"] + # default table header + table_header = ["index", "name", "phone", "email"] + plural = "" + if config.show_kinds or len(selected_kinds) > 1 or CarddavObject._default_kind not in selected_kinds: + table_header.append("kind") + if len(selected_address_books) > 1: + plural = "s" + table_header.append("address_book") + if not parsable: + print("Address book{}: {}".format(plural, ', '.join( + str(book) for book in selected_address_books))) if config.show_uids: table_header.append("uid") @@ -342,7 +205,7 @@ def list_contacts(vcard_list: List[CarddavObject], fields: Iterable[str] = (), for field in table_header: if field == 'index': row.append(str(index + 1)) - elif field in ['name', 'phone', 'email']: + elif field in ['name', 'phone', 'email', 'kind']: row.append(formatter.get_special_field(vcard, field)) elif field == 'uid': if parsable: @@ -380,7 +243,7 @@ def choose_address_book_from_list(header_string: str, list_address_books(address_books) # For all intents and purposes of select() an AddressBookCollection can # also be considered a List[VdirAddressBook]. - return select(cast(List[VdirAddressBook], address_books)) + return interactive.select(cast(List[VdirAddressBook], address_books)) def choose_vcard_from_list(header_string: str, vcard_list: List[CarddavObject], @@ -392,70 +255,56 @@ def choose_vcard_from_list(header_string: str, vcard_list: List[CarddavObject], return vcard_list[0] print(header_string) list_contacts(vcard_list) - return select(vcard_list, True) + return interactive.select(vcard_list, True) -def get_contact_list_by_user_selection( - address_books: Union[VdirAddressBook, AddressBookCollection], - query: Query) -> List[CarddavObject]: +def get_contact_list(address_books: Union[VdirAddressBook, + AddressBookCollection], + query: Query) -> List[CarddavObject]: """Find contacts in the given address book grouped, sorted and reversed - acording to the loaded configuration . + according to the loaded configuration. :param address_books: the address book to search :param query: the query to use when searching :returns: list of found CarddavObject objects """ - return get_contacts(address_books, query, config.reverse, - config.group_by_addressbook, config.sort) + contacts = address_books.search(query) + return sort_contacts(contacts, config.reverse, config.group_by_addressbook, + config.sort) -def get_contacts(address_book: Union[VdirAddressBook, AddressBookCollection], - query: Query, reverse: bool = False, group: bool = False, - sort: str = "first_name") -> List[CarddavObject]: - """Get a list of contacts from one or more address books. +def sort_contacts(contacts: Iterable[CarddavObject], reverse: bool = False, + group: bool = False, sort: str = "first_name") -> List[ + CarddavObject]: + """Sort a list of contacts - :param address_book: the address book to search - :param query: a search query to select contacts + :param contacts: the contact list to sort :param reverse: reverse the order of the returned contacts :param group: group results by address book :param sort: the field to use for sorting, one of "first_name", "last_name", "formatted_name" - :returns: contacts from the address_book that match the query + :returns: sorted contact list """ - # Search for the contacts in all address books. - contacts = address_book.search(query) - # Sort the contacts. + keys: List[Callable] = [] if group: - if sort == "first_name": - return sorted(contacts, reverse=reverse, key=lambda x: ( - unidecode(x.address_book.name).lower(), - unidecode(x.get_first_name_last_name()).lower())) - if sort == "last_name": - return sorted(contacts, reverse=reverse, key=lambda x: ( - unidecode(x.address_book.name).lower(), - unidecode(x.get_last_name_first_name()).lower())) - if sort == "formatted_name": - return sorted(contacts, reverse=reverse, key=lambda x: ( - unidecode(x.address_book.name).lower(), - unidecode(x.formatted_name.lower()))) + keys.append(operator.attrgetter("address_book.name")) + if sort == "first_name": + keys.append(operator.methodcaller("get_first_name_last_name")) + elif sort == "last_name": + keys.append(operator.methodcaller("get_last_name_first_name")) + elif sort == "formatted_name": + keys.append(operator.attrgetter("formatted_name")) else: - if sort == "first_name": - return sorted(contacts, reverse=reverse, key=lambda x: - unidecode(x.get_first_name_last_name()).lower()) - if sort == "last_name": - return sorted(contacts, reverse=reverse, key=lambda x: - unidecode(x.get_last_name_first_name()).lower()) - if sort == "formatted_name": - return sorted(contacts, reverse=reverse, key=lambda x: - unidecode(x.formatted_name.lower())) - raise ValueError('sort must be "first_name", "last_name" or ' - '"formatted_name" not {}.'.format(sort)) + raise ValueError('sort must be "first_name", "last_name" or ' + '"formatted_name" not {}.'.format(sort)) + return sorted(contacts, reverse=reverse, + key=lambda x: [unidecode(key(x)).lower() for key in keys]) 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 befor + 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. @@ -504,8 +353,7 @@ def generate_contact_list(args: Namespace) -> List[CarddavObject]: # It is simpler to handle subcommand that do not have and need search # terms here than conditionally calling generate_contact_list(). return [] - return get_contact_list_by_user_selection(args.addressbook, - args.search_terms) + return get_contact_list(args.addressbook, args.search_terms) def new_subcommand(selected_address_books: AddressBookCollection, @@ -533,7 +381,7 @@ def new_subcommand(selected_address_books: AddressBookCollection, config.private_objects, config.preferred_vcard_version, config.localize_dates) except ValueError as err: - sys.exit(err) + sys.exit(str(err)) else: new_contact.write_to_file() if open_editor: @@ -545,69 +393,219 @@ def new_subcommand(selected_address_books: AddressBookCollection, def add_email_to_contact(name: str, email_address: str, - abooks: AddressBookCollection) -> None: + abooks: AddressBookCollection, skip_already_added: bool) -> None: """Add a new email address to the given contact, creating the contact if necessary. :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 skip_already_added: skip if email_address is part of one or more contacts """ - print("Email address: {}".format(email_address)) + + # email address + # search in contacts + matching_contact_list = get_contact_list(abooks, TermQuery(email_address)) + if matching_contact_list: + matching_contact_list_to_string = ', '.join( + str(i) for i in matching_contact_list) + if skip_already_added: + print("Skipping email address {}: Is already part of {}" + .format(email_address, matching_contact_list_to_string)) + return + if not confirm("Email address: {}, Found in contacts: {}. Select anyway?" + .format(email_address, matching_contact_list_to_string)): + return + else: + if name: + name_and_email = '"{}" <{}>'.format(name, email_address) + else: + name_and_email = email_address + if not confirm("New address: {}. Select?".format(name_and_email)): + return + + # name if not name: + # ask for name name = input("Contact's name: ") + else: + # remove chars: " ' + name = name.replace('"', '').replace('\'', '') + # backup name for the "create new contact" function part below + original_name = name + + # select contact + previous_name = name + previous_selected_vcard = None + manual_search = False + while True: + query: Query + # search for an existing contact + name_parts = name.replace(',', '').split() + if len(name_parts) == 0: + query = AnyQuery() + elif len(name_parts) == 1: + query = TermQuery(name) + else: + 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) + + # select contact from list + if manual_search: + selected_vcard = choose_vcard_from_list( + "Select contact for the search term: {}".format(name), + found_vcard_list, include_none=True) + if found_vcard_list and not selected_vcard: + # contact selection cancelled + # restore previous data + name = previous_name + selected_vcard = previous_selected_vcard + manual_search = False + else: + selected_vcard = choose_vcard_from_list( + "Select contact for the found e-mail address", + found_vcard_list) - # search for an existing contact - selected_vcard = choose_vcard_from_list( - "Select contact for the found e-mail address", - get_contact_list_by_user_selection(abooks, TermQuery(name))) + break_outer = False + while True: + if selected_vcard is None: + if found_vcard_list: + answer = input("Contact selection cancelled (c/s/q): ") + 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') + 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() - if selected_vcard is None: - if not name: - return + if selected_vcard: + if answer == 'y': + break_outer = True + break + if answer == 'd': + print("\n{}".format(selected_vcard.pretty())) + continue + if answer == 'c': + selected_vcard = None + break_outer = True + break + if answer == 's': + # save data + previous_name = name + previous_selected_vcard = selected_vcard + # enter search string + if original_name: + name = input("Search for contact [ENTER='{}' or -='']: " + .format(original_name)) or original_name + if name == "-": + name = "" + else: + name = input("Search for contact: ") + manual_search = True + break + if answer == 'q': + print("Cancelled") + return + print(error_message) - # create new contact - if not confirm("Contact '{}' does not exist. Do you want to create it?" - .format(name)): - print("Cancelled") - return - # 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", config.abooks) - if selected_address_book is None: - sys.exit("Error: address book list is empty") + if break_outer: + # restore name + name = original_name + break + # create new contact + if selected_vcard is None: + # first and last name variables name_parts = name.split() - first = name_parts[0] if len(name_parts) > 0 else "" + # detect format: last_name, first_name in name variable + if name.count(",") == 1 \ + and len(name_parts) > 1 \ + and name_parts[0].endswith(","): + # remove "," from presumed last name + name_parts[0] = name_parts[0].replace(',', '') + # put last_name to the list end + name_parts.append(name_parts.pop(0)) + # fill variables + first = name_parts[0] if len(name_parts) > 0 else name last = name_parts[-1] if len(name_parts) > 1 else "" + # ask for address book, in which to create the new contact + if not config.abooks: + sys.exit("Error: address book list is empty") + else: + selected_address_book = choose_address_book_from_list( + "Select address book for new contact", config.abooks) + if selected_address_book is None: + print("No address book selected") + return + # ask for name and organisation of new contact while True: if first: - first_name = input("First name [empty for '{}']: ".format(first)) - if not first_name: - first_name = first + first_name = input("First name [ENTER='{}' or -='']: " + .format(first)) or first + if first_name == "-": + first_name = "" else: first_name = input("First name: ") if last: - last_name = input("Last name [empty for '{}']: ".format(last)) - if not last_name: - last_name = last + last_name = input("Last name [ENTER='{}' or -='']: " + .format(last)) or last + if last_name == "-": + last_name = "" else: last_name = input("Last name: ") - organisation = input("Organisation: ") + if name and not first_name and not last_name: + # first and last names are empty, maybe it's an organisation + organisation = input("Organisation [ENTER='{}' or -='']: " + .format(name)) or name + if organisation == "-": + organisation = "" + else: + organisation = input("Organisation: ") + if not first_name and not last_name and not organisation: print("Error: All fields are empty.") else: + print("") break + + # create contact + # + # build template + template_data = list() + if first_name: + template_data.append("First name : {}".format(first_name)) + if last_name: + template_data.append("Last name : {}".format(last_name)) + if organisation: + template_data.append("Organisation : {}".format(organisation)) + # confirm contact creation + print("Verify input data\n{}" + .format(textwrap.indent('\n'.join(template_data), 2*' '))) + if not confirm("Create contact?", False): + print("Cancelled") + return selected_vcard = CarddavObject.from_yaml( - selected_address_book, - "First name : {}\nLast name : {}\nOrganisation : {}".format( - first_name, last_name, organisation), - config.private_objects, config.preferred_vcard_version, - config.localize_dates) + selected_address_book, '\n'.join(template_data), + config.private_objects, config.preferred_vcard_version, + config.localize_dates) + if not selected_vcard: + print("Could not create contact") + return + print("Contact created successfully") # check if the contact already contains the email address for _, email_list in sorted(selected_vcard.emails.items(), @@ -618,12 +616,6 @@ def add_email_to_contact(name: str, email_address: str, .format(selected_vcard, email_address)) return - # ask for confirmation again - if not confirm("Do you want to add the email address {} to the contact {}?" - .format(email_address, selected_vcard)): - print("Cancelled") - return - # ask for the email label print("\nAdding email address {} to contact {}\n" "Enter email label\n" @@ -674,12 +666,14 @@ def extract_addresses(header) -> List[Address]: def add_email_subcommand( text: str, abooks: AddressBookCollection, - fields: List[str]) -> None: + fields: List[str], + skip_already_added: bool) -> None: """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 field: the header field to extract contacts from + :param skip_already_added: skip already known email addresses """ email_addresses = find_email_addresses(text, fields) if not email_addresses: @@ -691,7 +685,7 @@ def add_email_subcommand( name = email_address.display_name address = email_address.addr_spec - add_email_to_contact(name, address, abooks) + add_email_to_contact(name, address, abooks, skip_already_added) print() @@ -744,9 +738,12 @@ def birthdays_subcommand(vcard_list: List[CarddavObject], parsable: bool sys.exit(1) -def phone_subcommand(vcard_list: List[CarddavObject], parsable: bool) -> None: +def phone_subcommand(search_terms: Query, vcard_list: List[CarddavObject], + parsable: bool) -> None: """Print a phone application friendly contact table. + :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 be printed :param parsable: machine readable output: columns devided by tabulator (\t) @@ -756,6 +753,7 @@ def phone_subcommand(vcard_list: List[CarddavObject], parsable: bool) -> None: config.show_nicknames, parsable) numbers = [] for vcard in vcard_list: + field_line_list = [] for type, number_list in sorted(vcard.phone_numbers.items(), key=lambda k: k[0].lower()): for number in sorted(number_list): @@ -766,7 +764,9 @@ def phone_subcommand(vcard_list: List[CarddavObject], parsable: bool) -> None: else: # else: start with name fields = name, type, number - numbers.append("\t".join(fields)) + field_line_list.append("\t".join(fields)) + numbers += _filter_email_post_or_phone_number_results( + search_terms, field_line_list) if numbers: if parsable: print('\n'.join(numbers)) @@ -778,10 +778,13 @@ def phone_subcommand(vcard_list: List[CarddavObject], parsable: bool) -> None: sys.exit(1) -def post_address_subcommand(vcard_list: List[CarddavObject], parsable: bool +def post_address_subcommand(search_terms: Query, + vcard_list: List[CarddavObject], parsable: bool ) -> None: """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 be printed :param parsable: machine readable output: columns devided by tabulator (\t) @@ -793,20 +796,22 @@ def post_address_subcommand(vcard_list: List[CarddavObject], parsable: bool for vcard in vcard_list: name = formatter.get_special_field(vcard, "name") # create post address line list - contact_addresses = [] + field_line_list = [] if parsable: for type, post_addresses in sorted(vcard.post_addresses.items(), key=lambda k: k[0].lower()): for post_address in post_addresses: - contact_addresses.append([str(post_address), name, type]) + field_line_list.append( + "\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): - contact_addresses.append([name, type, address]) - for addr in contact_addresses: - addresses.append("\t".join(addr)) + field_line_list.append( + "\t".join([ name, type, address ])) + addresses += _filter_email_post_or_phone_number_results( + search_terms, field_line_list) if addresses: if parsable: print('\n'.join(addresses)) @@ -843,6 +848,7 @@ def email_subcommand(search_terms: Query, vcard_list: List[CarddavObject], config.show_nicknames, parsable) emails = [] for vcard in vcard_list: + field_line_list = [] for type, email_list in sorted(vcard.emails.items(), key=lambda k: k[0].lower()): for email in sorted(email_list): @@ -853,7 +859,9 @@ def email_subcommand(search_terms: Query, vcard_list: List[CarddavObject], else: # else: start with name fields = name, type, email - emails.append("\t".join(fields)) + field_line_list.append("\t".join(fields)) + emails += _filter_email_post_or_phone_number_results( + search_terms, field_line_list) if emails: if parsable: if not remove_first_line: @@ -870,6 +878,23 @@ def email_subcommand(search_terms: Query, vcard_list: List[CarddavObject], sys.exit(1) +def _filter_email_post_or_phone_number_results(search_terms: Query, + field_line_list: List[str]) -> List[str]: + """Filter the created output of phone_subcommand, post_address_subcommand + and email_subcommand by the given search term again. + If no match is found, return the complete input list + + :param search_terms: used as search term to filter the contacts before + printing + :param field_line_list: The line-by-line output of the commands listed above + """ + matched_line_list = [] + for line in field_line_list: + if search_terms and search_terms.match(line): + matched_line_list.append(line) + return matched_line_list if matched_line_list else field_line_list + + def list_subcommand(vcard_list: List[CarddavObject], parsable: bool, fields: List[str]) -> None: """Print a user friendly contacts table. @@ -900,19 +925,12 @@ def modify_subcommand(selected_vcard: CarddavObject, :param source: edit the source file or a yaml version? """ if source: - edit(selected_vcard.filename) + 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 - if selected_vcard.version not in config.supported_vcard_versions: - print("Warning:\nThe selected contact is based on vcard version {} " - "but khard only supports the creation and modification of vcards" - " with version 3.0 and 4.0.\nIf you proceed, the contact will be" - " converted to vcard version {} but beware: This could corrupt " - "the contact file or cause data loss.".format( - selected_vcard.version, config.preferred_vcard_version)) - if not confirm("Do you want to proceed anyway?"): - print("Canceled") - return + if not version_check(selected_vcard, "selected contact"): + return # if there is some data in stdin if input_from_stdin_or_file: # create new contact from stdin @@ -921,7 +939,7 @@ def modify_subcommand(selected_vcard: CarddavObject, selected_vcard, input_from_stdin_or_file, config.localize_dates) except ValueError as err: - sys.exit(err) + sys.exit(str(err)) if selected_vcard == new_contact: print("Nothing changed\n\n{}".format(new_contact.pretty())) else: @@ -964,7 +982,7 @@ def merge_subcommand(vcard_list: List[CarddavObject], :param search_terms: the search terms to find the target contact """ # Find possible target contacts. - target_vcards = get_contact_list_by_user_selection(abooks, search_terms) + target_vcards = get_contact_list(abooks, search_terms) # get the source vcard, from which to merge source_vcard = choose_vcard_from_list("Select contact from which to merge", vcard_list) @@ -1022,8 +1040,8 @@ def copy_or_move_subcommand(action: str, vcard_list: List[CarddavObject], # check if a contact already exists in the target address book target_vcard = choose_vcard_from_list( "Select target contact to overwrite (or None to add a new entry)", - get_contact_list_by_user_selection( - target_abook, TermQuery(source_vcard.formatted_name)), True) + get_contact_list(target_abook, TermQuery(source_vcard.formatted_name)), + True) # If the target contact doesn't exist, move or copy the source contact into # the target address book without further questions. if target_vcard is None: @@ -1099,7 +1117,7 @@ def main(argv: List[str] = sys.argv[1:]) -> None: sys.exit("{}\nUse --debug for more information or --skip-unparsable " "to proceed".format(err)) except AddressBookNameError as err: - sys.exit(err) + sys.exit(str(err)) vcard_list = generate_contact_list(args) @@ -1115,21 +1133,21 @@ def main(argv: List[str] = sys.argv[1:]) -> None: try: with open(args.input_file, "r") as infile: input_from_stdin_or_file = infile.read() - except IOError as err: + except OSError as err: sys.exit("Error: {}\n File: {}".format(err.strerror, err.filename)) elif not sys.stdin.isatty(): # try to read from stdin try: input_from_stdin_or_file = sys.stdin.read() - except IOError: + except OSError: sys.exit("Error: Can't read from stdin") # try to reopen console # otherwise further user interaction is not possible (for example # selecting a contact from the contact table) try: sys.stdin = open('/dev/tty') - except IOError: + except OSError: pass if args.action == "new": @@ -1137,13 +1155,14 @@ def main(argv: List[str] = sys.argv[1:]) -> None: args.open_editor) elif args.action == "add-email": add_email_subcommand(input_from_stdin_or_file, - args.addressbook, args.fields) + args.addressbook, args.headers, + args.skip_already_added) elif args.action == "birthdays": birthdays_subcommand(vcard_list, args.parsable) elif args.action == "phone": - phone_subcommand(vcard_list, args.parsable) + phone_subcommand(args.search_terms, vcard_list, args.parsable) elif args.action == "postaddress": - post_address_subcommand(vcard_list, args.parsable) + post_address_subcommand(args.search_terms, vcard_list, args.parsable) elif args.action == "email": email_subcommand(args.search_terms, vcard_list, args.parsable, args.remove_first_line) diff --git a/khard/object_type.py b/khard/object_type.py deleted file mode 100644 index 87ce5b4c..00000000 --- a/khard/object_type.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Helper module for validating typed vcard properties""" - -from enum import Enum - - -class ObjectType(Enum): - string = 1 - list_with_strings = 2 - string_or_list_with_strings = 3 diff --git a/khard/query.py b/khard/query.py index d03bf2af..4789e5c2 100644 --- a/khard/query.py +++ b/khard/query.py @@ -4,10 +4,14 @@ from datetime import datetime from functools import reduce from operator import and_, or_ +import re from typing import cast, Any, Dict, List, Optional, Union from . import carddav_object +# constants +FIELD_PHONE_NUMBERS = "phone_numbers" + class Query(metaclass=abc.ABCMeta): @@ -250,6 +254,81 @@ def __str__(self) -> str: return 'name:{}'.format(self._term) +class PhoneNumberQuery(FieldQuery): + + """A special query to match against phone numbers.""" + + @staticmethod + def _strip_phone_number(number: str) -> str: + return re.sub("[^0-9+]", "", number) + + def __init__(self, value: str) -> None: + super().__init__(FIELD_PHONE_NUMBERS, value) + self._term_only_digits = self._strip_phone_number(value) + + def match(self, thing: Union[str, "carddav_object.CarddavObject"]) -> bool: + if isinstance(thing, str): + return self._match_union(thing) + else: + return super().match(thing) + + def _match_union(self, value: Union[str, datetime, List, Dict[str, Any]] + ) -> bool: + if isinstance(value, str): + if self._term in value.lower() \ + or self._match_phone_number(self._strip_phone_number(value)): + return True + if isinstance(value, dict): + for key in value: + if self._term in str(key).lower(): + return True + if isinstance(value[key], str): + if self._match_phone_number( + self._strip_phone_number(value[key])): + return True + if isinstance(value[key], list): + for number in value[key]: + if self._match_phone_number( + self._strip_phone_number(number)): + return True + return False + # this should actually be a type error + return False + + def _match_phone_number(self, number: str) -> bool: + if self._term_only_digits.startswith("+") and number.startswith("+"): + # _term_only_digits: +49123456789 + # 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 + # _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 + # _term_only_digits: 0123456789 + # number: +49123456789 + if len(self._term_only_digits) >= 5: + # don't strip the leading "0" if the search term is too short + # otherwise you may get false positives + # _term could contain the latter part of a phone number instead + return self._term_only_digits[1:] in number + # end of special cases + if self._term_only_digits: + return self._term_only_digits in number + return False + + def __eq__(self, other: object) -> bool: + return isinstance(other, PhoneNumberQuery) and self._term == other._term + + def __hash__(self) -> int: + return hash((PhoneNumberQuery, self._term)) + + def __str__(self) -> str: + return 'phone numbers:{}'.format(self._term) + + def parse(string: str) -> Union[TermQuery, FieldQuery]: """Parse a string into a query object @@ -267,6 +346,8 @@ def parse(string: str) -> Union[TermQuery, FieldQuery]: field, term = string.split(":", maxsplit=1) if field == "name": return NameQuery(term) + if field == FIELD_PHONE_NUMBERS: + return PhoneNumberQuery(term) if field in carddav_object.CarddavObject.get_properties(): return FieldQuery(field, term) return TermQuery(string) diff --git a/misc/zsh/_khard b/misc/zsh/_khard index 7d38a806..155c57f1 100644 --- a/misc/zsh/_khard +++ b/misc/zsh/_khard @@ -54,7 +54,6 @@ case $state in {copy,cp}:'copy a contact to another addressbook' {details,show}:'show details for a contact' email:'list email addresses' - export:'export a contact' {filename,file}':list internal file names' {list,ls}:'list all (selected) contacts' merge:'merge two contacts' @@ -127,7 +126,7 @@ case $state in case $words[1] in addressbooks|abooks|template) options+=();; - source|src|remove|delete|del|rm|filename|file) + remove|delete|del|rm|filename|file) options+=( $default_addressbook_options $default_search_options $sort_options );; diff --git a/setup.py b/setup.py index e93fb0bf..6c3175bd 100644 --- a/setup.py +++ b/setup.py @@ -15,11 +15,11 @@ author='Eric Scheibler', author_email='email@eric-scheibler.de', url='https://github.com/lucc/khard/', - description='A console carddav client', + description='A console address book manager', long_description=readme, long_description_content_type='text/markdown', license='GPL', - keywords='Carddav console addressbook', + keywords='vcard console addressbook', classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console", @@ -37,14 +37,18 @@ 'unidecode', 'vobject' ], - extras_require={'doc': ['sphinx', 'sphinx-autoapi', - 'sphinx-autodoc-typehints']}, + extras_require={'doc': [ + 'sphinx', + 'sphinx-autoapi', + 'sphinx-autodoc-typehints' + ]}, use_scm_version={'write_to': 'khard/version.py'}, setup_requires=['setuptools_scm'], - packages=['khard'], + 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.6", + python_requires=">=3.7", include_package_data=True, ) diff --git a/test/fixture/vcards/individual.vcf b/test/fixture/vcards/individual.vcf new file mode 100644 index 00000000..06986b28 --- /dev/null +++ b/test/fixture/vcards/individual.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +UID:18F098B5-7383-4FD6-B482-48F2181D73AA +N:Coyote;Wile;E.;; +FN:Wile E. Coyote +ORG:ACME Inc.; +END:VCARD diff --git a/test/fixture/vcards/org.vcf b/test/fixture/vcards/org.vcf new file mode 100644 index 00000000..c8ff2e47 --- /dev/null +++ b/test/fixture/vcards/org.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +UID:429A43AB-52F2-4714-AA62-077528A12464 +KIND:org +FN:ACME Inc. +ORG:ACME Inc.; +END:VCARD diff --git a/test/helpers.py b/test/helpers.py index 6dded7df..8df98b32 100644 --- a/test/helpers.py +++ b/test/helpers.py @@ -72,14 +72,15 @@ def load_contact(path, abook=None): class TmpAbook: """Context manager to create a temporary address book folder""" - def __init__(self, vcards): + def __init__(self, vcards, name="tmp"): self.vcards = vcards + self.name = name def __enter__(self): self.tempdir = tempfile.TemporaryDirectory() for card in self.vcards: shutil.copy(self._card_path(card), self.tempdir.name) - return address_book.VdirAddressBook("tmp", self.tempdir.name) + return address_book.VdirAddressBook(self.name, self.tempdir.name) def __exit__(self, _a, _b, _c): self.tempdir.cleanup() diff --git a/test/test_actions.py b/test/test_actions.py index ecaf2c41..c1b11f4c 100644 --- a/test/test_actions.py +++ b/test/test_actions.py @@ -24,11 +24,13 @@ def test_get_action_returns_none_for_unknown(self): def test_get_aliases_reverse_resolves_aliases(self): self.assertEqual([alias], actions.Actions.get_aliases(action)) - def test_get_aliases_returns_none_for_aliases(self): - self.assertIsNone(actions.Actions.get_aliases(alias)) + def test_get_aliases_throws_keyerror_for_aliases(self): + with self.assertRaises(KeyError): + actions.Actions.get_aliases(alias) - def test_get_aliases_returns_none_for_unknown(self): - self.assertIsNone(actions.Actions.get_aliases(unknown)) + def test_get_aliases_throws_keyerror_for_unknown(self): + with self.assertRaises(KeyError): + actions.Actions.get_aliases(unknown) def test_get_actions_returns_actions(self): self.assertIn(action, actions.Actions.get_actions()) diff --git a/test/test_cli.py b/test/test_cli.py index bc66907d..53ff0ae8 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,5 +1,7 @@ """Tests for the cli module""" +from argparse import ArgumentTypeError +import tempfile import unittest from unittest import mock @@ -9,6 +11,32 @@ from .helpers import mock_stream +class TestFieldsArgument(unittest.TestCase): + + def test_works_when_choices_match(self): + t = cli.FieldsArgument("a", "b") + actual = t("a,b") + expected = ["a", "b"] + self.assertListEqual(actual, expected) + + def test_raises_exception_when_choices_dont_match(self): + t = cli.FieldsArgument("a", "b") + with self.assertRaises(ArgumentTypeError): + t("a,c") + + def test_case_does_not_matter(self): + t = cli.FieldsArgument("a", "b") + actual = t("a,B") + expected = ["a", "b"] + self.assertListEqual(actual, expected) + + def test_only_first_component_must_match_choices_with_nested(self): + t = cli.FieldsArgument("a", "b", nested=True) + actual = t("a.c,b") + expected = ["a.c", "b"] + self.assertListEqual(actual, expected) + + @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') class TestParseArgs(unittest.TestCase): @@ -80,27 +108,44 @@ def test_no_target_specification_results_in_an_any_query(self): def test_add_email_defaults_to_from_lowercase(self): args, _config = cli.parse_args(["add-email"]) - actual = args.fields + actual = args.headers self.assertEqual(["from"], actual) def test_add_email_from_field(self): args, _config = cli.parse_args(["add-email", "-H", "from"]) - actual = args.fields + actual = args.headers self.assertEqual(["from"], actual) def test_add_email_another_field(self): args, _config = cli.parse_args(["add-email", "-H", "OtHer"]) - actual = args.fields + actual = args.headers self.assertEqual(["other"], actual) def test_add_email_multiple_headers_separate_args_takes_last(self): args, _config = cli.parse_args( ["add-email", "-H", "OtHer", "-H", "myfield"]) - actual = args.fields + actual = args.headers self.assertEqual(["myfield"], actual) def test_add_email_multiple_headers_comma_separated(self): args, _config = cli.parse_args( ["add-email", "-H", "OtHer,myfield,from"]) - actual = args.fields + actual = args.headers self.assertEqual(["other", "myfield", "from"], actual) + + def test_exit_user_friendly_without_config_file(self): + with self.assertRaises(SystemExit): + cli.parse_args(["-c", "/this file should hopefully never exist."]) + + def test_exit_user_friendly_without_contacts_folder(self): + with tempfile.NamedTemporaryFile("w", delete=False) as config: + config.write("""[general] + editor = editor + merge_editor = merge_editor + [addressbooks] + [[tmp]] + path = /this file should hopefully never exist. + """) + config.flush() + with self.assertRaises(SystemExit): + cli.init(["-c", config.name, "ls"]) diff --git a/test/test_command_line_interface.py b/test/test_command_line_interface.py index 0c16e021..5c6ab2c2 100644 --- a/test/test_command_line_interface.py +++ b/test/test_command_line_interface.py @@ -21,6 +21,7 @@ from khard import cli from khard import config +from khard.helpers.interactive import EditState, Editor from khard import khard from .helpers import TmpConfig, mock_stream @@ -209,6 +210,27 @@ def test_postaddr_lists_only_contacts_with_post_addresses(self): ' SomeState, HomeCountry'] self.assertListEqual(expect, text) + def test_mixed_kinds(self): + with TmpConfig(["org.vcf", "individual.vcf"]): + stdout = run_main("list", "organisations:acme") + text = [line.rstrip() for line in stdout.getvalue().splitlines()] + expected = [ + "Address book: tmp", + "Index Name Phone Email Kind Uid", + "1 ACME Inc. organisation 4", + "2 Wile E. Coyote individual 1"] + self.assertListEqual(expected, text) + + def test_non_individual_kind(self): + with TmpConfig(["org.vcf"]): + stdout = run_main("list") + text = [line.rstrip() for line in stdout.getvalue().splitlines()] + expected = [ + "Address book: tmp", + "Index Name Phone Email Kind Uid", + "1 ACME Inc. organisation 4"] + self.assertListEqual(expected, text) + class ListingCommands2(unittest.TestCase): @@ -379,11 +401,15 @@ def test_simple_show_with_yaml_format(self): @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf') def test_simple_edit_without_modification(self): - with mock.patch('subprocess.Popen') as popen: + editor = mock.Mock() + editor.edit_templates = mock.Mock(return_value=None) + editor.write_temp_file = Editor.write_temp_file + with mock.patch('khard.khard.interactive.Editor', + mock.Mock(return_value=editor)): run_main("edit", "uid1") # The editor is called with a temp file so how to we check this more # precisely? - popen.assert_called_once() + editor.edit_templates.assert_called_once() @mock.patch.dict('os.environ', KHARD_CONFIG='test/fixture/minimal.conf', EDITOR='editor') @@ -475,5 +501,29 @@ def test_merge_with_exact_uid_search_terms(self): self.assertEqual('contact2.vcf', second) +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 = [ + "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("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") + + if __name__ == "__main__": unittest.main() diff --git a/test/test_config.py b/test/test_config.py index 9b5a775f..c6e189ff 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -16,7 +16,7 @@ class LoadingConfigFile(unittest.TestCase): def test_load_non_existing_file_fails(self): filename = "I hope this file never exists" - with self.assertRaises(IOError) as cm: + with self.assertRaises(OSError) as cm: config.Config._load_config_file(filename) self.assertTrue(str(cm.exception).startswith('Config file not found:')) @@ -52,7 +52,7 @@ def test_load_empty_file_fails(self): @mock.patch.dict('os.environ', EDITOR='editor', MERGE_EDITOR='meditor') def test_load_minimal_file_by_name(self): cfg = config.Config("test/fixture/minimal.conf") - self.assertEqual(cfg.editor, "editor") + self.assertEqual(cfg.editor, ["editor"]) self.assertEqual(cfg.merge_editor, "meditor") @@ -94,6 +94,10 @@ def test_show_uids_defaults_to_true(self): c = config.Config("test/fixture/minimal.conf") self.assertTrue(c.show_uids) + def test_show_kinds_defaults_to_false(self): + c = config.Config("test/fixture/minimal.conf") + self.assertFalse(c.show_kinds) + def test_sort_defaults_to_first_name(self): c = config.Config("test/fixture/minimal.conf") self.assertEqual(c.sort, 'first_name') @@ -133,7 +137,7 @@ def test_preferred_version_defaults_to_3(self): @mock.patch.dict('os.environ', clear=True) def test_editor_defaults_to_vim(self): c = config.Config("test/fixture/minimal.conf") - self.assertEqual(c.editor, 'vim') + self.assertEqual(c.editor, ['vim']) @mock.patch.dict('os.environ', clear=True) def test_merge_editor_defaults_to_vimdiff(self): diff --git a/test/test_editor.py b/test/test_editor.py new file mode 100644 index 00000000..379ac2b8 --- /dev/null +++ b/test/test_editor.py @@ -0,0 +1,95 @@ +"""Tests for editing files and contacts in an external editor""" + +from contextlib import contextmanager +import datetime +import unittest +from unittest import mock + +from khard.helpers.interactive import Editor, EditState + +from .helpers import mock_stream + + +class EditFiles(unittest.TestCase): + + t1 = datetime.datetime(2021, 1, 1, 12, 21, 42) + t2 = datetime.datetime(2021, 1, 1, 12, 21, 43) + editor = Editor("edit", "merge") + + @staticmethod + @contextmanager + def _mock_popen(returncode=0): + """Mock the subprocess.Popen class, set the returncode attribute of the + child process object.""" + child_process = mock.Mock() + child_process.returncode = returncode + Popen = mock.Mock(return_value=child_process) + with mock.patch("subprocess.Popen", Popen) as popen: + yield popen + + @staticmethod + def _edit_files(write="changed"): + """Mock function for khar.helpers.interactive.Editor.edit_files + + Create a function that will write the specified text to all files + passed as arguments. + """ + def edit_files(self, *files): + for f in files: + with open(f, "w") as fp: + fp.write(write) + return edit_files + + def test_calls_subprocess_popen_with_editor_for_one_args(self): + with self._mock_popen() as popen: + with mock.patch("khard.helpers.interactive.Editor._mtime", + mock.Mock(return_value=self.t1)): + self.editor.edit_files("file") + popen.assert_called_with(["edit", "file"]) + + def test_calls_subprocess_popen_with_merge_editor_for_two_args(self): + with self._mock_popen() as popen: + with mock.patch("khard.helpers.interactive.Editor._mtime", + mock.Mock(return_value=self.t1)): + self.editor.edit_files("file1", "file2") + popen.assert_called_with(["merge", "file1", "file2"]) + + def test_failing_external_command_returns_aborted_state(self): + with self._mock_popen(1): + with mock.patch("khard.helpers.interactive.Editor._mtime", + mock.Mock(return_value=self.t1)): + actual = self.editor.edit_files("file") + self.assertEqual(actual, EditState.aborted) + + def test_returns_state_modiefied_if_timestamp_does_change(self): + with self._mock_popen(): + with mock.patch("khard.helpers.interactive.Editor._mtime", + mock.Mock(side_effect=[self.t1, self.t2])): + actual = self.editor.edit_files("file") + self.assertEqual(actual, EditState.modified) + + def test_returns_state_unmodiefied_if_timestamp_does_not_change(self): + with self._mock_popen(): + with mock.patch("khard.helpers.interactive.Editor._mtime", + mock.Mock(side_effect=[self.t1, self.t1])): + actual = self.editor.edit_files("file") + self.assertEqual(actual, EditState.unmodified) + + def test_editing_templates(self): + t1 = "some: yaml\ndocument: true\n" + with mock.patch("khard.helpers.interactive.Editor.edit_files", + self._edit_files()): + actual = self.editor.edit_templates(lambda x: x, t1) + self.assertEqual(actual, "changed") + + def test_exception_from_yaml_conversion_is_caught(self): + t1 = "key: value\n" + with mock.patch("khard.helpers.interactive.Editor.edit_files", + self._edit_files()): + with mock.patch("khard.helpers.interactive.confirm", + mock.Mock(return_value=False)) as confirm: + with mock_stream(): # hide stdout in test + actual = self.editor.edit_templates( + mock.Mock(side_effect=ValueError), t1) + self.assertIsNone(actual) + confirm.assert_called_once() diff --git a/test/test_helpers.py b/test/test_helpers.py index 67abcf28..17a88916 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -1,107 +1,11 @@ """Tests for the helpers module.""" # pylint: disable=missing-docstring -import datetime import unittest from khard import helpers -class ListToString(unittest.TestCase): - - def test_empty_list_returns_empty_string(self): - the_list = [] - delimiter = ' ' - expected = '' - actual = helpers.list_to_string(the_list, delimiter) - self.assertEqual(actual, expected) - - def test_simple_list(self): - the_list = ['a', 'bc', 'def'] - delimiter = ' ' - expected = 'a bc def' - actual = helpers.list_to_string(the_list, delimiter) - self.assertEqual(actual, expected) - - def test_simple_nested_list(self): - the_list = ['a', 'bc', ['x', 'y', 'z'], 'def'] - delimiter = ' ' - expected = 'a bc x y z def' - actual = helpers.list_to_string(the_list, delimiter) - self.assertEqual(actual, expected) - - def test_multi_level_nested_list(self): - the_list = ['a', ['b', ['c', [[['x', 'y']]]]], 'z'] - delimiter = ' ' - expected = 'a b c x y z' - actual = helpers.list_to_string(the_list, delimiter) - self.assertEqual(actual, expected) - - def test_list_to_string_passes_through_other_objects(self): - self.assertIs(helpers.list_to_string(None, "foo"), None) - self.assertIs(helpers.list_to_string(42, "foo"), 42) - self.assertIs(helpers.list_to_string("foo bar", "foo"), "foo bar") - - -class StringToDate(unittest.TestCase): - - date = datetime.datetime(year=1900, month=1, day=2) - time = datetime.datetime(year=1900, month=1, day=2, hour=12, minute=42, - second=17) - zone = datetime.datetime(year=1900, month=1, day=2, hour=12, minute=42, - second=17, tzinfo=datetime.timezone.utc) - - def test_mmdd_format(self): - string = '--0102' - result = helpers.string_to_date(string) - self.assertEqual(result, self.date) - - def test_mm_dd_format(self): - string = '--01-02' - result = helpers.string_to_date(string) - self.assertEqual(result, self.date) - - def test_yyyymmdd_format(self): - string = '19000102' - result = helpers.string_to_date(string) - self.assertEqual(result, self.date) - - def test_yyyy_mm_dd_format(self): - string = '1900-01-02' - result = helpers.string_to_date(string) - self.assertEqual(result, self.date) - - def test_yyyymmddThhmmss_format(self): - string = '19000102T124217' - result = helpers.string_to_date(string) - self.assertEqual(result, self.time) - - def test_yyyy_mm_ddThh_mm_ss_format(self): - string = '1900-01-02T12:42:17' - result = helpers.string_to_date(string) - self.assertEqual(result, self.time) - - def test_yyyymmddThhmmssZ_format(self): - string = '19000102T124217Z' - result = helpers.string_to_date(string) - self.assertEqual(result, self.time) - - def test_yyyy_mm_ddThh_mm_ssZ_format(self): - string = '1900-01-02T12:42:17Z' - result = helpers.string_to_date(string) - self.assertEqual(result, self.time) - - def test_yyyymmddThhmmssz_format(self): - string = '19000102T064217-06:00' - result = helpers.string_to_date(string) - self.assertEqual(result, self.zone) - - def test_yyyy_mm_ddThh_mm_ssz_format(self): - string = '1900-01-02T06:42:17-06:00' - result = helpers.string_to_date(string) - self.assertEqual(result, self.zone) - - class ConvertToYAML(unittest.TestCase): def test_colon_handling(self): diff --git a/test/test_helpers_interactive.py b/test/test_helpers_interactive.py new file mode 100644 index 00000000..d8e35f9a --- /dev/null +++ b/test/test_helpers_interactive.py @@ -0,0 +1,70 @@ +"""Tests for the user interaction functions.""" + +import unittest +from unittest import mock + +from khard.helpers import interactive + +from .helpers import mock_stream + + +class Select(unittest.TestCase): + + def _test(expected, include_none=None): + input_list = ["a", "b", "c"] + if include_none is None: + return interactive.select(input_list) + else: + return interactive.select(input_list, include_none) + + def test_selection_index_is_1_based(self): + with mock.patch("builtins.input", lambda x: "1"): + actual = self._test() + self.assertEqual(actual, "a") + + def test_typing_a_non_number_prints_a_message_and_repeats(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["foo", "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_out_of_bounds_repeats(self): + with mock.patch("builtins.input", mock.Mock(side_effect=["5", "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"): + self.assertTrue(interactive.confirm("")) + + def test_n_is_false(self): + with mock.patch("builtins.input", lambda x: "n"): + self.assertFalse(interactive.confirm("")) + + def test_Y_is_true(self): + with mock.patch("builtins.input", lambda x: "Y"): + self.assertTrue(interactive.confirm("")) + + def test_N_is_false(self): + with mock.patch("builtins.input", lambda x: "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"])): + 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"])): + with mock_stream(): + self.assertTrue(interactive.confirm("")) diff --git a/test/test_helpers_typing.py b/test/test_helpers_typing.py new file mode 100644 index 00000000..a9492cec --- /dev/null +++ b/test/test_helpers_typing.py @@ -0,0 +1,145 @@ +"""Tests for runtime type conversions""" + +import datetime +import unittest + +from khard.helpers.typing import convert_to_vcard, list_to_string, \ + ObjectType, string_to_date + + +class ConvertToVcard(unittest.TestCase): + + def test_returns_strings(self): + value = "some text" + actual = convert_to_vcard("test", value, ObjectType.str) + self.assertEqual(value, actual) + + def test_returns_lists(self): + value = ["some", "text"] + actual = convert_to_vcard("test", value, ObjectType.list) + self.assertListEqual(value, actual) + + def test_fail_if_not_string(self): + value = ["some", "text"] + with self.assertRaises(ValueError): + convert_to_vcard("test", value, ObjectType.str) + + def test_upgrades_string_to_list(self): + value = "some text" + actual = convert_to_vcard("test", value, ObjectType.list) + self.assertListEqual([value], actual) + + def test_fails_if_string_lists_are_not_homogenous(self): + value = ["some", ["nested", "list"]] + with self.assertRaises(ValueError): + convert_to_vcard("test", value, ObjectType.list) + + def test_empty_list_items_are_filtered(self): + value = ["some", "", "text", "", "more text"] + actual = convert_to_vcard("test", value, ObjectType.list) + self.assertListEqual(["some", "text", "more text"], actual) + + def test_strings_are_stripped(self): + value = " some text " + actual = convert_to_vcard("test", value, ObjectType.str) + self.assertEqual("some text", actual) + + def test_strings_in_lists_are_stripped(self): + value = [" some ", " text "] + actual = convert_to_vcard("test", value, ObjectType.list) + self.assertListEqual(["some", "text"], actual) + + +class ListToString(unittest.TestCase): + + def test_empty_list_returns_empty_string(self): + the_list = [] + delimiter = ' ' + expected = '' + actual = list_to_string(the_list, delimiter) + self.assertEqual(actual, expected) + + def test_simple_list(self): + the_list = ['a', 'bc', 'def'] + delimiter = ' ' + expected = 'a bc def' + actual = list_to_string(the_list, delimiter) + self.assertEqual(actual, expected) + + def test_simple_nested_list(self): + the_list = ['a', 'bc', ['x', 'y', 'z'], 'def'] + delimiter = ' ' + expected = 'a bc x y z def' + actual = list_to_string(the_list, delimiter) + self.assertEqual(actual, expected) + + def test_multi_level_nested_list(self): + the_list = ['a', ['b', ['c', [[['x', 'y']]]]], 'z'] + delimiter = ' ' + expected = 'a b c x y z' + actual = list_to_string(the_list, delimiter) + self.assertEqual(actual, expected) + + def test_list_to_string_passes_through_other_objects(self): + self.assertIs(list_to_string(None, "foo"), None) + self.assertIs(list_to_string(42, "foo"), 42) + self.assertIs(list_to_string("foo bar", "foo"), "foo bar") + + +class StringToDate(unittest.TestCase): + + date = datetime.datetime(year=1900, month=1, day=2) + time = datetime.datetime(year=1900, month=1, day=2, hour=12, minute=42, + second=17) + zone = datetime.datetime(year=1900, month=1, day=2, hour=12, minute=42, + second=17, tzinfo=datetime.timezone.utc) + + def test_mmdd_format(self): + string = '--0102' + result = string_to_date(string) + self.assertEqual(result, self.date) + + def test_mm_dd_format(self): + string = '--01-02' + result = string_to_date(string) + self.assertEqual(result, self.date) + + def test_yyyymmdd_format(self): + string = '19000102' + result = string_to_date(string) + self.assertEqual(result, self.date) + + def test_yyyy_mm_dd_format(self): + string = '1900-01-02' + result = string_to_date(string) + self.assertEqual(result, self.date) + + def test_yyyymmddThhmmss_format(self): + string = '19000102T124217' + result = string_to_date(string) + self.assertEqual(result, self.time) + + def test_yyyy_mm_ddThh_mm_ss_format(self): + string = '1900-01-02T12:42:17' + result = string_to_date(string) + self.assertEqual(result, self.time) + + def test_yyyymmddThhmmssZ_format(self): + string = '19000102T124217Z' + result = string_to_date(string) + self.assertEqual(result, self.time) + + def test_yyyy_mm_ddThh_mm_ssZ_format(self): + string = '1900-01-02T12:42:17Z' + result = string_to_date(string) + self.assertEqual(result, self.time) + + def test_yyyymmddThhmmssz_format(self): + string = '19000102T064217-06:00' + result = string_to_date(string) + self.assertEqual(result, self.zone) + + def test_yyyy_mm_ddThh_mm_ssz_format(self): + string = '1900-01-02T06:42:17-06:00' + result = string_to_date(string) + self.assertEqual(result, self.zone) diff --git a/test/test_khard.py b/test/test_khard.py index 6a9cd397..f3bb807a 100644 --- a/test/test_khard.py +++ b/test/test_khard.py @@ -8,7 +8,7 @@ from khard import khard, query, config from khard.khard import find_email_addresses -from .helpers import TmpAbook +from .helpers import TmpAbook, load_contact class TestSearchQueryPreparation(unittest.TestCase): @@ -177,32 +177,76 @@ def tearDown(self): def test_uid_query_without_strict_search(self): q = query.FieldQuery("uid", "testuid1") with TmpAbook(["contact1.vcf", "contact2.vcf"]) as abook: - l = khard.get_contact_list_by_user_selection(abook, q) + l = khard.get_contact_list(abook, q) self.assertEqual(len(l), 1) self.assertEqual(l[0].uid, 'testuid1') def test_name_query_with_uid_text_and_strict_search(self): q = query.NameQuery("testuid1") with TmpAbook(["contact1.vcf", "contact2.vcf"]) as abook: - l = khard.get_contact_list_by_user_selection(abook, q) + l = khard.get_contact_list(abook, q) self.assertEqual(len(l), 0) def test_name_query_with_uid_text_and_without_strict_search(self): q = query.NameQuery("testuid1") with TmpAbook(["contact1.vcf", "contact2.vcf"]) as abook: - l = khard.get_contact_list_by_user_selection(abook, q) + l = khard.get_contact_list(abook, q) self.assertEqual(len(l), 0) def test_term_query_without_strict_search(self): q = query.TermQuery("testuid1") with TmpAbook(["contact1.vcf", "contact2.vcf"]) as abook: - l = khard.get_contact_list_by_user_selection(abook, q) + l = khard.get_contact_list(abook, q) self.assertEqual(len(l), 1) self.assertEqual(l[0].uid, 'testuid1') def test_term_query_with_strict_search_matching(self): q = query.TermQuery("second contact") with TmpAbook(["contact1.vcf", "contact2.vcf"]) as abook: - l = khard.get_contact_list_by_user_selection(abook, q) + l = khard.get_contact_list(abook, q) self.assertEqual(len(l), 1) self.assertEqual(l[0].uid, 'testuid1') + + +class TestSortContacts(unittest.TestCase): + + contact1 = load_contact("contact1.vcf") + contact2 = load_contact("contact2.vcf") + nickname = load_contact("nickname.vcf") + no_nickname = load_contact("no-nickname.vcf") + + def _test(self, first, second, **kwargs): + """Run the sort_contacts function and assert the result + + The two contacts first and second are expected to come out in that + order and are deliberatly put into the function in the reverse order. + """ + actual = khard.sort_contacts([second, first], **kwargs) + self.assertListEqual(actual, [first, second]) + + def test_sorts_by_first_name_by_default(self): + self._test(self.nickname, self.no_nickname) + + def test_reverses_sort_order(self): + self._test(self.no_nickname, self.nickname, reverse=True) + + def test_can_sort_by_last_name(self): + self._test(self.no_nickname, self.nickname, sort="last_name") + + def test_can_sort_by_formatted_name(self): + self._test(self.contact1, self.contact2, sort="formatted_name") + + def test_group_by_addressbook(self): + with TmpAbook(["contact1.vcf", "category.vcf"], name="one") as abook1: + with TmpAbook(["contact2.vcf", "labels.vcf"], + name="two") as abook2: + contact1 = next(abook1.search(query.FieldQuery("uid", + "testuid1"))) + category = next(abook1.search(query.NameQuery("category"))) + contact2 = next(abook2.search(query.FieldQuery("uid", + "testuid2"))) + labels = next(abook2.search(query.NameQuery("labeled guy"))) + expected = [category, contact1, labels, contact2] + actual = khard.sort_contacts([contact1, contact2, category, labels], + group=True) + self.assertListEqual(actual, expected) diff --git a/test/test_query.py b/test/test_query.py index 5eb75872..e026bc47 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -43,6 +43,12 @@ def test_failes_if_at_least_one_subterm_fails(self): q = AndQuery(q1, q2) self.assertFalse(q.match("ac")) + def test_order_does_not_matter(self): + q1 = TermQuery("a") + q2 = TermQuery("b") + q = AndQuery(q1, q2) + self.assertTrue(q.match("ab")) + self.assertTrue(q.match("ba")) class TestOrQuery(unittest.TestCase): @@ -58,6 +64,13 @@ def test_failes_if_all_subterms_fail(self): q = OrQuery(q1, q2) self.assertFalse(q.match("cd")) + def test_order_does_not_matter(self): + q1 = TermQuery("a") + q2 = TermQuery("b") + q = OrQuery(q1, q2) + self.assertTrue(q.match("ab")) + self.assertTrue(q.match("ba")) + class TestEquality(unittest.TestCase): @@ -72,7 +85,7 @@ def test_or_queries_match_after_sorting(self): any = AnyQuery() term = TermQuery("foo") field = FieldQuery("x", "y") - first = OrQuery(null, any , term, field) + first = OrQuery(null, any, term, field) second = OrQuery(any, null, field, term) self.assertEqual(first, second) @@ -81,7 +94,7 @@ def test_and_queries_match_after_sorting(self): any = AnyQuery() term = TermQuery("foo") field = FieldQuery("x", "y") - first = AndQuery(null, any , term, field) + first = AndQuery(null, any, term, field) second = AndQuery(any, null, field, term) self.assertEqual(first, second) diff --git a/test/test_yaml.py b/test/test_yaml.py index a68032aa..f43c372e 100644 --- a/test/test_yaml.py +++ b/test/test_yaml.py @@ -40,52 +40,62 @@ class EmptyFieldsAndSpaces(unittest.TestCase): def test_empty_birthday_in_yaml_input(self): empty_birthday = "First name: foo\nBirthday:" - x = parse_yaml(empty_birthday) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_birthday) self.assertIsNone(x.birthday) def test_only_spaces_in_birthday_in_yaml_input(self): spaces_birthday = "First name: foo\nBirthday: " - x = parse_yaml(spaces_birthday) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(spaces_birthday) self.assertIsNone(x.birthday) def test_empty_anniversary_in_yaml_input(self): empty_anniversary = "First name: foo\nAnniversary:" - x = parse_yaml(empty_anniversary) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_anniversary) self.assertIsNone(x.anniversary) def test_empty_organisation_in_yaml_input(self): empty_organisation = "First name: foo\nOrganisation:" - x = parse_yaml(empty_organisation) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_organisation) self.assertListEqual(x.organisations, []) def test_empty_nickname_in_yaml_input(self): empty_nickname = "First name: foo\nNickname:" - x = parse_yaml(empty_nickname) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_nickname) self.assertListEqual(x.nicknames, []) def test_empty_role_in_yaml_input(self): empty_role = "First name: foo\nRole:" - x = parse_yaml(empty_role) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_role) self.assertListEqual(x.roles, []) def test_empty_title_in_yaml_input(self): empty_title = "First name: foo\nTitle:" - x = parse_yaml(empty_title) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_title) self.assertListEqual(x.titles, []) def test_empty_categories_in_yaml_input(self): empty_categories = "First name: foo\nCategories:" - x = parse_yaml(empty_categories) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_categories) self.assertListEqual(x.categories, []) def test_empty_webpage_in_yaml_input(self): empty_webpage = "First name: foo\nWebpage:" - x = parse_yaml(empty_webpage) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_webpage) self.assertListEqual(x.webpages, []) def test_empty_note_in_yaml_input(self): empty_note = "First name: foo\nNote:" - x = parse_yaml(empty_note) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(empty_note) self.assertListEqual(x.notes, []) @@ -94,7 +104,8 @@ class yaml_ablabel(unittest.TestCase): def test_ablabelled_url_in_yaml_input(self): ablabel_url = "First name: foo\nWebpage:\n - http://example.com\n" \ " - github: https://github.com/scheibler/khard" - x = parse_yaml(ablabel_url) + with mock.patch("khard.carddav_object.logger"): + x = parse_yaml(ablabel_url) self.assertListEqual(x.webpages, [ 'http://example.com', {'github': 'https://github.com/scheibler/khard'}]) diff --git a/test/test_yaml_editable.py b/test/test_yaml_editable.py new file mode 100644 index 00000000..b9460131 --- /dev/null +++ b/test/test_yaml_editable.py @@ -0,0 +1,34 @@ +"""Tests for the carddav_object.YAMLEditable class""" + +import unittest + +from .helpers import TestYAMLEditable + + +class ToYamlConversion(unittest.TestCase): + + def test_yaml_quoted_special_characters(self): + yaml_editable = TestYAMLEditable() + yaml_editable.supported_private_objects = ["Twitter"] + yaml_repr = """ +Formatted name: Test vCard +First name: Khard +Private : + Twitter: \"@khard\" +""" + yaml_editable.update(yaml_repr) + yaml_dump = yaml_editable.to_yaml() + self.assertIn("'@khard'", yaml_dump) + + +class ExceptionHandling(unittest.TestCase): + + def test_duplicate_key_errors_are_translated_to_value_errors(self): + ye = TestYAMLEditable() + with self.assertRaises(ValueError): + ye.update("{key: value, key: again}") + + def test_parser_error_is_translated_to_value_error(self): + ye = TestYAMLEditable() + with self.assertRaises(ValueError): + ye.update("{[invalid yaml") diff --git a/todo.txt b/todo.txt index 4ebae2ab..7e5aa3ad 100644 --- a/todo.txt +++ b/todo.txt @@ -1,9 +1,6 @@ ToDo list for khard 1. Add support for vcard attributes kind and member - - kind column in contact table - - option to filter contact table (--kind) - member action to list all members of an organisation 2. Implement impp attribute, see #105 for more information -