diff --git a/khard/cli.py b/khard/cli.py index 55f98a7f..ba1688cf 100644 --- a/khard/cli.py +++ b/khard/cli.py @@ -95,7 +95,7 @@ def create_parsers() -> tuple[argparse.ArgumentParser, new_addressbook_parser.add_argument( "-a", "--addressbook", default=[], type=lambda x: [y.strip() for y in x.split(",")], - help="Specify address book in which to create the new contact") + help="Specify address book in which to create the new contact(s)") copy_move_addressbook_parser = argparse.ArgumentParser(add_help=False) copy_move_addressbook_parser.add_argument( "-a", "--addressbook", default=[], @@ -207,7 +207,16 @@ def create_parsers() -> tuple[argparse.ArgumentParser, "-o", "--output-file", default=sys.stdout, type=argparse.FileType("w"), help="Specify output template file name or use stdout by default") - subparsers.add_parser("template", help="print an empty yaml template") + template_parser = subparsers.add_parser( + "template", + description="print an empty yaml (default) or CSV template", + help="print an empty yaml (default) or CSV template") + template_parser.add_argument( + "-O", "--format", choices=("yaml", "csv"), default="yaml", + help="select the template format") + template_parser.add_argument( + "-d", "--delimiter", default=",", + help="Use DELIMITER instead of \",\" for CSV field delimiter") birthdays_parser = subparsers.add_parser( "birthdays", aliases=Actions.get_aliases("birthdays"), @@ -259,11 +268,17 @@ def create_parsers() -> tuple[argparse.ArgumentParser, "new", aliases=Actions.get_aliases("new"), parents=[new_addressbook_parser, template_input_file_parser], - description="create a new contact", - help="create a new contact") + description="create a new contact or new contacts", + help="create a new contact or new contacts") new_parser.add_argument( "--vcard-version", choices=("3.0", "4.0"), dest='preferred_version', help="Select preferred vcard version for new contact") + new_parser.add_argument( + "-O", "--format", choices=("yaml", "csv"), default="yaml", + help="Select input format (yaml by default)") + new_parser.add_argument( + "-d", "--delimiter", default=",", + help="Use DELIMITER instead of \",\" for CSV field delimiter") add_email_parser = subparsers.add_parser( "add-email", aliases=Actions.get_aliases("add-email"), diff --git a/khard/contacts.py b/khard/contacts.py index 4d05fa91..c0ab2a8c 100644 --- a/khard/contacts.py +++ b/khard/contacts.py @@ -1001,10 +1001,11 @@ def _filter_invalid_tags(contents: str) -> str: flags=re.IGNORECASE) return contents - @staticmethod - def _parse_yaml(input: str) -> dict: - """Parse a YAML document into a dictionary and validate the data to - some degree. + @classmethod + def _parse_yaml(cls, input: str) -> dict: + """Parse a YAML document into a dictionary. + + And validate to some degree. :param input: the YAML document to parse :returns: the parsed data structure @@ -1020,8 +1021,19 @@ def _parse_yaml(input: str) -> dict: if not contact_data: raise ValueError("Found no contact information") - # check for available data - # at least enter name or organisation + return cls._validate(contact_data) + + @staticmethod + def _validate(contact_data: dict) -> dict: + """Validate contact data to some degree. + + Ensure that at a name or organisation has been entered. + + :param contact_data: dict of contact data, as returned by + YAMLEditable._parse_yaml() + :returns: the same value + :raises: ValueError + """ if not (contact_data.get("First name") or contact_data.get("Last name") or contact_data.get("Organisation")): raise ValueError("You must either enter a name or an organisation") @@ -1088,12 +1100,18 @@ def _set_date(self, target: str, key: str, data: dict) -> None: "Use format yyyy-mm-dd or " "yyyy-mm-ddTHH:MM:SS") - def update(self, input: str) -> None: - """Update this vcard with some yaml input + def update(self, input: str | dict) -> None: + """Update this vcard with yaml input or with a dict of contact data. - :param input: a yaml string to parse and then use to update self + :param input: a yaml string to parse and then use to update self, or a + dict of the same structure as the dict returned by + self._parse_yaml() """ - contact_data = self._parse_yaml(input) + if isinstance(input, str): + contact_data = self._parse_yaml(input) + elif isinstance(input, dict): + contact_data = self._validate(input) + # update rev self._update_revision() @@ -1458,6 +1476,22 @@ def from_yaml(cls, address_book: "address_book.VdirAddressBook", yaml: str, contact.update(yaml) return contact + @classmethod + def from_dict(cls, address_book: "address_book.VdirAddressBook", + data: dict, + supported_private_objects: list[str] | None = None, + version: str | None = None, localize_dates: bool = False + ) -> "Contact": + """Use this if you want to create a new contact from a dict. + + The dict must have the same structure as the dict returned by + cls._parse_yaml(). + """ + contact = cls.new(address_book, supported_private_objects, version, + localize_dates=localize_dates) + contact.update(data) + return contact + @classmethod def clone_with_yaml_update(cls, contact: "Contact", yaml: str, localize_dates: bool = False diff --git a/khard/csv.py b/khard/csv.py new file mode 100644 index 00000000..addb2479 --- /dev/null +++ b/khard/csv.py @@ -0,0 +1,209 @@ +from collections.abc import Iterator +import csv +import re +from typing import Any + + +class Parser: + """An iterator over rows in a CSV file that returns contact data.""" + + def __init__(self, input_from_stdin_or_file: str, delimiter: str) -> None: + """Parse first row to determine structure of contact data. + + :param input_from_stdin_or_file: A string from stdin, from an input + file specified with "-i" or "--input-file", or otherwise from a + temporary file created by khard and edited by the user. + :param delimiter: The field delimiter ("," by default). + """ + self.reader = csv.reader(input_from_stdin_or_file.split("\n"), + delimiter=delimiter) + first_row = next(self.reader) + self.template, self.columns = self._parse_headers(first_row) + + def __iter__(self) -> Iterator[dict]: + return self + + def __next__(self) -> dict: + """Return the next parsed row from the CSV reader. + + Iteration stops when "reader" raises "StopIteration", or when row is + blank. + + :returns: A dict with the same structure as the dict returned by + khard.YAMLEditable._parse_yaml(). Can be passed to + khard.YAMLEditable.update(). + """ + try: + row = next(self.reader) + except StopIteration: + raise + else: + if not row: + raise StopIteration + return self.parse(row) + + def parse(self, row: list[str]) -> dict: + """Get data from a CSV row that can be used to make a new Contact. + + :param row: A list of strings, one for each column. + :returns: A dict with the same structure as the dict returned by + khard.YAMLEditable._parse_yaml(). Can be passed to + khard.YAMLEditable.update(). + """ + self._get_data(row) + return self._process_data() + + @staticmethod + def _parse_headers(first_row: list[str]) -> tuple[dict, list]: + """Determine the data structure of each contact by parsing first row. + + Valid headers have the form "[ [ - ]]". + + If the column header has the form "", each value in the column is + a string indexed by "". If the column header has the form " + ", each value in the column is a string, at index "", in + a list indexed by "". If the column header has the form " + - ", each value in the column is a value in a dict + indexed by "". This dict is in a list indexed by "key", at + index "". + + For example, the following CSV would have the following raw data + structure: + + First name,Last name,Organisation 1,Organisation 2,Email 1 - + type,Email 1 - value,Email 2 - type + + Bruce,Wayne,Justice League,Wayne + Enterprises,work,thebat@justice.org,work,bruce@wayne.com + + {'First name': 'Bruce', + 'Last name': 'Wayne', + 'Organisation': {1: 'Justice League', 2: 'Wayne Enterprises'}, + 'Email': {1: {'type': 'work'}, 2: {'value': 'thebat@justice.org'}}} + + Note that, rather than actual lists, we use dicts with numeric keys. + This is to avoid making assumptions about how users will structure + their CSV files. For example, if a user for some reason placed "Email + 2" before "Email 1", and we were storing email data in a list, that + would lead to an IndexError. A dict, on the other hand, does not care + if key "1" does not yet exist when mapping a value to key "2". + + :param first_row: First row of the CSV file, which must contain column + headers. + :returns: The "template" dict and the "columns" list. The structure of + "template" is determined by the CSV column headers, and all of its + keys are initialized. "columns" is a list of 2-tuples. The first + item in each tuple is the data structure in which each value in + that column belongs. The second item is the index in that data + structure at which the value is located. + """ + template: dict[str, Any] = {} + columns: list[tuple[dict, Any]] = [] + + headers = re.compile(r"^([a-zA-Z ]+)(?: (\d+))?(?: - ([a-zA-Z ]+))?$") + for val in first_row: + match = headers.search(val) + if not match: + raise ValueError(f"Column header \"{val}\" is invalid.") + else: + key, idx, subkey = match.groups() + + if idx: + idx = int(idx) - 1 + template.setdefault(key, {}) + if subkey: + template[key].setdefault(idx, {}) + template[key][idx].update({subkey: None}) + columns.append( + (template[key][idx], subkey) + ) + else: + template[key].setdefault(idx, None) + columns.append( + (template[key], idx) + ) + else: + template[key] = None + columns.append( + (template, key) + ) + + return template, columns + + def _get_data(self, row: list[str]) -> None: + """Populate "self.template" with data using info in "self.columns". + + We have to fill in "self.template" in place, rather than a copy, + because the data structures referenced in "self.columns" point to + "self.template" itself. This approach is safe because every value in + "self.template" is overwritten on every iteration. + """ + for i in range(0, len(row)): + data_structure, idx = self.columns[i] + data_structure[idx] = row[i] + + def _process_data(self) -> dict: + """Process raw data into a form that can be used to create Contacts. + + Turn dicts with numeric keys into actual lists, if the keys index + strings. For example, the line `'Organisation': {1: 'Justice League', + 2: 'Wayne Enterprises'}` becomes `'Organisation': ['Justice League', + 'Wayne Enterprises']`. + + Turn dicts with numeric keys into dicts with string keys, if the keys + index dicts. If any of the indexed dicts contains two keys, "type" and + "value", the value indexed by "type" is a key in the new dict, and the + value indexed by "value" is mapped to that key. For example, the line + `'Email': {1: {'type': 'work', 'value': 'thebat@justice.org'}, 2: + {'type': 'home', 'value': 'bruce@gmail.com}` becomes `'Email': + {'work': 'thebat@justice.org', 'home': 'bruce@gmail.com'}`. + + If any of the indexed dicts contain the key "type" but not the key + "value", "type" is a key in the new dict, and all other key-value + pairs in the indexed dict are key-value pairs in a dict mapped to that + key. For example, the line `'Address': {1: {'type': 'home', 'Street': + '1007 Mountain Drive', 'City': 'Gotham City', 'Country': 'USA'}}` + becomes `'Address': {'home': {'Street': '1007 Mountain Drive', 'City': + 'Gotham City', 'Country': 'USA'}}`. + + If any of the indexed dicts have the same value mapped to key "type", + the value indexed by "type" indexes a list in the new dict. The list + contains all of the values that could have been mapped to the key, if + any of the dicts had been the only dict with a "type" of that value. + For example, `'Email': {1: {'type': 'work', 'value': + 'thebat@justice.org'}, 2: {'type': 'work', 'value': + 'bruce@wayne.com'}` becomes `'Email': {'work': ['thebat@justice.org', + 'bruce@wayne.com']}`. + + :returns: A dict with the same structure as the dict returned by + khard.YAMLEditable._parse_yaml(). Can be passed to + khard.YAMLEditable.update(). + """ + contact_data = {} + for key, val in self.template.items(): + if not isinstance(val, dict): + contact_data[key] = val + elif not isinstance(val[0], dict): + contact_data[key] = [val[k] for k in sorted(val.keys()) + if val[k]] + elif list(sorted(val[0].keys())) == ["type", "value"]: + contact_data[key] = {} + for d in val.values(): + if not d["type"]: + continue + try: + contact_data[key][d["type"]].append(d["value"]) + except KeyError: + contact_data[key][d["type"]] = [d["value"]] + else: + contact_data[key] = {} + for d in val.values(): + if not d["type"]: + continue + try: + contact_data[key][d["type"]].append(d) + del contact_data[key][d["type"]][-1]["type"] + except KeyError: + contact_data[key][d["type"]] = [d] + del contact_data[key][d["type"]][-1]["type"] + return contact_data diff --git a/khard/data/template.csv b/khard/data/template.csv new file mode 100644 index 00000000..3f1ae744 --- /dev/null +++ b/khard/data/template.csv @@ -0,0 +1 @@ +Formatted name,Kind,Prefix,First name,Additional 1,Additional 2,Last name,Suffix,Nickname,Anniversary,Birthday,Organisation 1,Organisation 2,Title,Role,Phone 1 - type,Phone 1 - value,Phone 2 - type,Phone 2 - value,Email 1 - type,Email 1 - value,Email 2 - type,Email 2 - value,Address 1 - type,Address 1 - Box,Address 1 - Extended,Address 1 - Street,Address 1 - Code,Address 1 - City,Address 1 - Region,Address 1 - Country,Categories 1,Categories 2,Webpage,Note{} diff --git a/khard/helpers/__init__.py b/khard/helpers/__init__.py index e579fcce..eb33ef02 100644 --- a/khard/helpers/__init__.py +++ b/khard/helpers/__init__.py @@ -275,3 +275,18 @@ def get_new_contact_template( template = pathlib.Path(__file__).parent.parent / 'data' / 'template.yaml' with template.open() as temp: return temp.read().format('\n'.join(formatted_private_objects)) + + +def get_csv_template( + delimiter: str, + supported_private_objects: list[str] | None = []) -> str: + formatted_private_objects = [] + if supported_private_objects: + formatted_private_objects.append("") + for i in range(0, len(supported_private_objects)): + formatted_private_objects.append(f"Private {i + 1} - type") + formatted_private_objects.append(f"Private {i + 1} - value") + path = pathlib.Path(__file__).parent.parent / 'data' / 'template.csv' + with path.open() as temp: + template = temp.read().replace(",", delimiter) + return template.format(delimiter.join(formatted_private_objects)) diff --git a/khard/helpers/interactive.py b/khard/helpers/interactive.py index 2a95d58f..4a9f7cd7 100644 --- a/khard/helpers/interactive.py +++ b/khard/helpers/interactive.py @@ -6,10 +6,11 @@ import os.path import subprocess from tempfile import NamedTemporaryFile -from typing import Callable, Generator, Sequence, TypeVar +from typing import Any, Callable, Generator, Sequence, TypeVar from ..exceptions import Cancelled from ..contacts import Contact +from .. import csv T = TypeVar("T") @@ -122,13 +123,14 @@ def __init__(self, editor: str | list[str], @staticmethod @contextlib.contextmanager - def write_temp_file(text: str = "") -> Generator[str, None, None]: + def write_temp_file(text: str = "", suffix: str = ".yml" + ) -> 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: + with NamedTemporaryFile(mode='w+t', suffix=suffix) as tmp: tmp.write(text) tmp.flush() yield tmp.name @@ -197,3 +199,47 @@ def edit_templates(self, yaml2card: Callable[[str], Contact], print("Canceled") return None return None # only for mypy + + + def edit_csv_template(self, + dict2card: Callable[[dict[str, Any]], Contact], + template: str, delimiter: str + ) -> list[Contact] | None: + """Edit CSV template of contacts and parse them back + + :param dict2card: a function to convert each parsed row of the + modified CSV template into a Contact + :param template: the template + :returns: the list of parsed Contacts or None + """ + with contextlib.ExitStack() as stack: + filename = stack.enter_context( + self.write_temp_file(template, ".csv") + ) + # Try to edit the files until we detect a modification or the user + # aborts + while True: + if self.edit_files(filename) == EditState.unmodified: + return None + # read temp file contents after editing + with open(filename, "r") as tmp: + modified_template = tmp.read() + # No actual modification was done + if modified_template == template: + return None + + # try to create contacts from user input + parser = csv.Parser(modified_template, delimiter=delimiter) + new_contacts = [] + try: + for contact_data in parser: + new_contact = dict2card(contact_data) + new_contacts.append(new_contact) + except ValueError as err: + print(f"\nError: {err}\n") + if not confirm("Do you want to open the editor again?"): + print("Canceled") + return None + else: + return new_contacts + return None diff --git a/khard/khard.py b/khard/khard.py index f0c2bcbc..cc450c5a 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -19,6 +19,7 @@ from .contacts import Contact from . import cli from .config import Config +from . import csv from .formatter import Formatter from .helpers import interactive from .helpers.interactive import confirm @@ -65,6 +66,60 @@ def create_new_contact(address_book: VdirAddressBook) -> None: print("Creation successful\n\n{}".format(new_contact.pretty())) +def create_new_contacts(address_book: VdirAddressBook, delimiter: str) -> None: + editor = interactive.Editor(config.editor, config.merge_editor) + # create temp file + template = helpers.get_csv_template(delimiter, config.private_objects) + # create contact objects from temp file + new_contacts = editor.edit_csv_template(lambda t: Contact.from_dict( + address_book, t, config.private_objects, + config.preferred_vcard_version, config.localize_dates), + template, delimiter) + + if new_contacts is None: + print("Canceled") + else: + for new_contact in new_contacts: + new_contact.write_to_file() + print("Creation successful\n\n{}".format(new_contact.pretty())) + + +def import_new_contact(abook: VdirAddressBook, data: str, open_editor: bool + ) -> ExitStatus: + # create new contact from stdin/the input file + try: + new_contact = Contact.from_yaml( + abook, data, config.private_objects, + config.preferred_vcard_version, config.localize_dates) + except ValueError as err: + return str(err) + else: + new_contact.write_to_file() + if open_editor: + modify_existing_contact(new_contact) + else: + print("Creation successful\n\n{}".format(new_contact.pretty())) + + +def import_new_contacts(abook: VdirAddressBook, delimiter: str, data: str, + open_editor: bool) -> ExitStatus: + # create new contacts from stdin/the input file + parser = csv.Parser(data, delimiter=delimiter) + for contact_data in parser: + try: + new_contact = Contact.from_dict( + abook, contact_data, config.private_objects, + config.preferred_vcard_version, config.localize_dates) + except ValueError as err: + return str(err) + else: + new_contact.write_to_file() + if open_editor: + modify_existing_contact(new_contact) + else: + print("Creation successful\n\n{}".format(new_contact.pretty())) + + def modify_existing_contact(old_contact: Contact) -> None: editor = interactive.Editor(config.editor, config.merge_editor) # create temp file and open it with the specified text editor @@ -367,39 +422,36 @@ def generate_contact_list(args: Namespace) -> list[Contact]: return get_contact_list(args.addressbook, args.search_terms) -def new_subcommand(abooks: AddressBookCollection, data: str, open_editor: bool - ) -> ExitStatus: +def new_subcommand(abooks: AddressBookCollection, fmt: str, delimiter: str, + data: str, open_editor: bool) -> ExitStatus: """Create a new contact. :param abooks: a list of address books that were selected on the command line - :param data: the data for the new contact as a yaml formatted string + :param fmt: the input format (yaml or csv) + :param delimiter: the CSV field delimiter + :param data: the data for the new contact as a yaml or CSV formatted + string :param open_editor: whether to open the new contact in the editor after creation :raises Canceled: when the user canceled a selection """ # ask for address book, in which to create the new contact abook = choose_address_book_from_list( - "Select address book for new contact", abooks) + "Select address book for new contact(s)", abooks) if abook is None: return "address book list is empty" # if there is some data in stdin/the input file if data: - # create new contact from stdin/the input file - try: - new_contact = Contact.from_yaml( - abook, data, config.private_objects, - config.preferred_vcard_version, config.localize_dates) - except ValueError as err: - return str(err) - else: - new_contact.write_to_file() - if open_editor: - modify_existing_contact(new_contact) - else: - print("Creation successful\n\n{}".format(new_contact.pretty())) + if fmt == 'yaml': + import_new_contact(abook, data, open_editor) + elif fmt == 'csv': + import_new_contacts(abook, delimiter, data, open_editor) else: - create_new_contact(abook) + if fmt == 'yaml': + create_new_contact(abook) + elif fmt == 'csv': + create_new_contacts(abook, delimiter) def add_email_to_contact(name: str, email_address: str, @@ -1101,12 +1153,15 @@ def main(argv: list[str] = sys.argv[1:]) -> ExitStatus: global config config = conf - # Check some of the simpler subcommands first. These don't have any - # options and can directly be run. + # Check some of the simpler subcommands first. if args.action == "addressbooks": print('\n'.join(str(book) for book in config.abooks)) return None if args.action == "template": + if args.format == 'csv': + print(helpers.get_csv_template( + args.delimiter, config.private_objects)) + return None print("# Contact template for khard version {}\n#\n" "# Use this yaml formatted template to create a new contact:\n" "# either with: khard new -a address_book -i template.yaml\n" @@ -1178,7 +1233,9 @@ def main(argv: list[str] = sys.argv[1:]) -> ExitStatus: # these commands require user interaction try: if args.action == "new": - return new_subcommand(args.addressbook, input_from_stdin_or_file, + return new_subcommand(args.addressbook, + args.format, args.delimiter, + input_from_stdin_or_file, args.open_editor) elif args.action == "add-email": return add_email_subcommand(input_from_stdin_or_file, diff --git a/test/fixture/csv/batman.yaml b/test/fixture/csv/batman.yaml new file mode 100644 index 00000000..a5b9297e --- /dev/null +++ b/test/fixture/csv/batman.yaml @@ -0,0 +1,161 @@ +# Contact template for khard version 0.1.dev1192+g40c9de648 +# +# Use this yaml formatted template to create a new contact: +# either with: khard new -a address_book -i template.yaml +# or with: cat template.yaml | khard new -a address_book + +# Every contact must contain a formatted name, it will be autofilled +# from the full name below if not given. +Formatted name : + +# kind (requires vcard 4.0) +# one of: individual, group, org, location, application, device +Kind : + +# name components +# every entry may contain a string or a list of strings +# format: +# First name : name1 +# Additional : +# - name2 +# - name3 +# Last name : name4 +Prefix : +First name : Bruce +Additional : +Last name : Wayne +Suffix : + +# nickname +# may contain a string or a list of strings +Nickname : Batman + +# important dates +# Formats: +# vcard 3.0 and 4.0: yyyy-mm-dd or yyyy-mm-ddTHH:MM:SS +# vcard 4.0 only: --mm-dd or text= string value +# anniversary +Anniversary : +# birthday +Birthday : + +# organisation +# format: +# Organisation : company +# or +# Organisation : +# - company1 +# - company2 +# or +# Organisation : +# - +# - company +# - unit +Organisation : + - Justice League + - Wayne Enterprises + +# organisation title and role +# every entry may contain a string or a list of strings +# +# title at organisation +# example usage: research scientist +Title : +# role at organisation +# example usage: project leader +Role : + +# phone numbers +# format: +# Phone: +# type1, type2: number +# type3: +# - number1 +# - number2 +# custom: number +# allowed types: +# vcard 3.0: At least one of bbs, car, cell, fax, home, isdn, msg, modem, +# pager, pcs, pref, video, voice, work +# vcard 4.0: At least one of home, work, pref, text, voice, fax, cell, video, +# pager, textphone +# Alternatively you may use a single custom label (only letters). +# But beware, that not all address book clients will support custom labels. +Phone : + cell : 911 + home : + +# email addresses +# format like phone numbers above +# allowed types: +# vcard 3.0: At least one of home, internet, pref, work, x400 +# vcard 4.0: At least one of home, internet, pref, work +# Alternatively you may use a single custom label (only letters). +Email : + home : batman39@dc.com + work : + - thebat@justice.org + - bruce@wayne.com + +# post addresses +# allowed types: +# vcard 3.0: At least one of dom, intl, home, parcel, postal, pref, work +# vcard 4.0: At least one of home, pref, work +# Alternatively you may use a single custom label (only letters). +Address : + work : + - + Box : + Extended : Hall of Justice + Street : + Code : + City : Washington D.C. + Region : + Country : USA + - + Box : + Extended : Wayne Enterprises + Street : + Code : + City : Gotham City + Region : + Country : USA + home : + Box : + Extended : + Street : 1007 Mountain Drive + Code : + City : Gotham City + Region : + Country : USA + +# categories or tags +# format: +# Categories : single category +# or +# Categories : +# - category1 +# - category2 +Categories : + +# web pages +# may contain a string or a list of strings +Webpage : + +# private objects +# define your own private objects in the vcard section of your khard config file +# example: +# [vcard] +# private_objects = Jabber, Skype, Twitter +# these objects are stored with a leading "X-" before the object name in the +# vcard files. +# every entry may contain a string or a list of strings +Private : + +# notes +# may contain a string or a list of strings +# for multi-line notes use: +# Note : | +# line one +# line two +Note : + diff --git a/test/fixture/csv/jumbled.csv b/test/fixture/csv/jumbled.csv new file mode 100644 index 00000000..d0f7c217 --- /dev/null +++ b/test/fixture/csv/jumbled.csv @@ -0,0 +1,4 @@ +Prefix,Address 3 - type,Additional 2,Email 1 - value,Address 2 - City,Phone 2 - value,Address 1 - Country,Address 2 - type,Address 2 - Country,Address 1 - City,Webpage,Address 1 - type,Birthday,Formatted name,Address 2 - Extended,Address 3 - Country,Address 2 - Region,Categories 2,Address 3 - Region,Organisation 2,Address 1 - Region,Categories 1,Address 1 - Street,Role,Address 1 - Extended,Phone 2 - type,Address 1 - Box,Email 3 - type,Organisation 1,Title,Address 2 - Code,Anniversary,Nickname,Address 2 - Street,Additional 1,Address 3 - Street,Last name,Address 3 - Box,Email 3 - value,Phone 1 - type,Email 1 - type,Kind,Email 2 - value,Address 3 - Extended,Note,Email 2 - type,Address 2 - Box,Address 3 - City,First name,Suffix,Address 3 - Code,Phone 1 - value,Address 1 - Code +,home,,thebat@justice.org,Gotham City,,USA,work,USA,Washington D.C.,,work,,,Wayne Enterprises,USA,,,,Wayne Enterprises,,,,,Hall of Justice,,,work,Justice League,,,,Batman,,,1007 Mountain Drive,Wayne,,bruce@wayne.com,cell,work,,batman39@dc.com,,,home,,Gotham City,Bruce,,,911, +,home,,theman@justice.org,Metropolis,,USA,work,USA,Washington D.C.,,work,,,The Daily Planet,USA,,,,The Daily Planet,,,,,Hall of Justice,,,work,Justice League,,,,Superman,,,344 Clinton Street,Kent,,kent@dailyplanet.com,cell,work,,,,,,,Metropolis,Clark,,,911, +,,,lane@dailyplanet.com,,,USA,,,Metropolis,,work,,,,,,,,,,,,,The Daily Planet,,,,The Daily Planet,,,,,,,,Lane,,,,work,,,,,,,,Lois,,,, diff --git a/test/fixture/csv/lois_lane.yaml b/test/fixture/csv/lois_lane.yaml new file mode 100644 index 00000000..07977cc9 --- /dev/null +++ b/test/fixture/csv/lois_lane.yaml @@ -0,0 +1,140 @@ +# Contact template for khard version 0.1.dev1192+g40c9de648 +# +# Use this yaml formatted template to create a new contact: +# either with: khard new -a address_book -i template.yaml +# or with: cat template.yaml | khard new -a address_book + +# Every contact must contain a formatted name, it will be autofilled +# from the full name below if not given. +Formatted name : + +# kind (requires vcard 4.0) +# one of: individual, group, org, location, application, device +Kind : + +# name components +# every entry may contain a string or a list of strings +# format: +# First name : name1 +# Additional : +# - name2 +# - name3 +# Last name : name4 +Prefix : +First name : Lois +Additional : +Last name : Lane +Suffix : + +# nickname +# may contain a string or a list of strings +Nickname : + +# important dates +# Formats: +# vcard 3.0 and 4.0: yyyy-mm-dd or yyyy-mm-ddTHH:MM:SS +# vcard 4.0 only: --mm-dd or text= string value +# anniversary +Anniversary : +# birthday +Birthday : + +# organisation +# format: +# Organisation : company +# or +# Organisation : +# - company1 +# - company2 +# or +# Organisation : +# - +# - company +# - unit +Organisation : The Daily Planet + +# organisation title and role +# every entry may contain a string or a list of strings +# +# title at organisation +# example usage: research scientist +Title : +# role at organisation +# example usage: project leader +Role : + +# phone numbers +# format: +# Phone: +# type1, type2: number +# type3: +# - number1 +# - number2 +# custom: number +# allowed types: +# vcard 3.0: At least one of bbs, car, cell, fax, home, isdn, msg, modem, +# pager, pcs, pref, video, voice, work +# vcard 4.0: At least one of home, work, pref, text, voice, fax, cell, video, +# pager, textphone +# Alternatively you may use a single custom label (only letters). +# But beware, that not all address book clients will support custom labels. +Phone : + cell : + home : + +# email addresses +# format like phone numbers above +# allowed types: +# vcard 3.0: At least one of home, internet, pref, work, x400 +# vcard 4.0: At least one of home, internet, pref, work +# Alternatively you may use a single custom label (only letters). +Email : + home : + work : lane@dailyplanet.com + +# post addresses +# allowed types: +# vcard 3.0: At least one of dom, intl, home, parcel, postal, pref, work +# vcard 4.0: At least one of home, pref, work +# Alternatively you may use a single custom label (only letters). +Address : + work : + Box : + Extended : The Daily Planet + Street : + Code : + City : Metropolis + Region : + Country : USA + +# categories or tags +# format: +# Categories : single category +# or +# Categories : +# - category1 +# - category2 +Categories : + +# web pages +# may contain a string or a list of strings +Webpage : + +# private objects +# define your own private objects in the vcard section of your khard config file +# example: +# [vcard] +# private_objects = Jabber, Skype, Twitter +# these objects are stored with a leading "X-" before the object name in the +# vcard files. +# every entry may contain a string or a list of strings +Private : + +# notes +# may contain a string or a list of strings +# for multi-line notes use: +# Note : | +# line one +# line two +Note : + diff --git a/test/fixture/csv/neat.csv b/test/fixture/csv/neat.csv new file mode 100644 index 00000000..0fb3ab8d --- /dev/null +++ b/test/fixture/csv/neat.csv @@ -0,0 +1,4 @@ +Formatted name,Kind,Prefix,First name,Additional 1,Additional 2,Last name,Suffix,Nickname,Anniversary,Birthday,Organisation 1,Organisation 2,Title,Role,Phone 1 - type,Phone 1 - value,Phone 2 - type,Phone 2 - value,Email 1 - type,Email 1 - value,Email 2 - type,Email 2 - value,Email 3 - type,Email 3 - value,Address 1 - type,Address 1 - Box,Address 1 - Extended,Address 1 - Street,Address 1 - Code,Address 1 - City,Address 1 - Region,Address 1 - Country,Address 2 - type,Address 2 - Box,Address 2 - Extended,Address 2 - Street,Address 2 - Code,Address 2 - City,Address 2 - Region,Address 2 - Country,Address 3 - type,Address 3 - Box,Address 3 - Extended,Address 3 - Street,Address 3 - Code,Address 3 - City,Address 3 - Region,Address 3 - Country,Categories 1,Categories 2,Webpage,Note +,,,Bruce,,,Wayne,,Batman,,,Justice League,Wayne Enterprises,,,cell,911,,,work,thebat@justice.org,home,batman39@dc.com,work,bruce@wayne.com,work,,Hall of Justice,,,Washington D.C.,,USA,work,,Wayne Enterprises,,,Gotham City,,USA,home,,,1007 Mountain Drive,,Gotham City,,USA,,,, +,,,Clark,,,Kent,,Superman,,,Justice League,The Daily Planet,,,cell,911,,,work,theman@justice.org,,,work,kent@dailyplanet.com,work,,Hall of Justice,,,Washington D.C.,,USA,work,,The Daily Planet,,,Metropolis,,USA,home,,,344 Clinton Street,,Metropolis,,USA,,,, +,,,Lois,,,Lane,,,,,The Daily Planet,,,,,,,,work,lane@dailyplanet.com,,,,,work,,The Daily Planet,,,Metropolis,,USA,,,,,,,,,,,,,,,,,,,, diff --git a/test/fixture/csv/superman.yaml b/test/fixture/csv/superman.yaml new file mode 100644 index 00000000..f546d05a --- /dev/null +++ b/test/fixture/csv/superman.yaml @@ -0,0 +1,161 @@ +# Contact template for khard version 0.1.dev1192+g40c9de648 +# +# Use this yaml formatted template to create a new contact: +# either with: khard new -a address_book -i template.yaml +# or with: cat template.yaml | khard new -a address_book + +# Every contact must contain a formatted name, it will be autofilled +# from the full name below if not given. +Formatted name : + +# kind (requires vcard 4.0) +# one of: individual, group, org, location, application, device +Kind : + +# name components +# every entry may contain a string or a list of strings +# format: +# First name : name1 +# Additional : +# - name2 +# - name3 +# Last name : name4 +Prefix : +First name : Clark +Additional : +Last name : Kent +Suffix : + +# nickname +# may contain a string or a list of strings +Nickname : Superman + +# important dates +# Formats: +# vcard 3.0 and 4.0: yyyy-mm-dd or yyyy-mm-ddTHH:MM:SS +# vcard 4.0 only: --mm-dd or text= string value +# anniversary +Anniversary : +# birthday +Birthday : + +# organisation +# format: +# Organisation : company +# or +# Organisation : +# - company1 +# - company2 +# or +# Organisation : +# - +# - company +# - unit +Organisation : + - Justice League + - The Daily Planet + +# organisation title and role +# every entry may contain a string or a list of strings +# +# title at organisation +# example usage: research scientist +Title : +# role at organisation +# example usage: project leader +Role : + +# phone numbers +# format: +# Phone: +# type1, type2: number +# type3: +# - number1 +# - number2 +# custom: number +# allowed types: +# vcard 3.0: At least one of bbs, car, cell, fax, home, isdn, msg, modem, +# pager, pcs, pref, video, voice, work +# vcard 4.0: At least one of home, work, pref, text, voice, fax, cell, video, +# pager, textphone +# Alternatively you may use a single custom label (only letters). +# But beware, that not all address book clients will support custom labels. +Phone : + cell : 911 + home : + +# email addresses +# format like phone numbers above +# allowed types: +# vcard 3.0: At least one of home, internet, pref, work, x400 +# vcard 4.0: At least one of home, internet, pref, work +# Alternatively you may use a single custom label (only letters). +Email : + home : + work : + - theman@justice.org + - kent@dailyplanet.com + +# post addresses +# allowed types: +# vcard 3.0: At least one of dom, intl, home, parcel, postal, pref, work +# vcard 4.0: At least one of home, pref, work +# Alternatively you may use a single custom label (only letters). +Address : + work : + - + Box : + Extended : Hall of Justice + Street : + Code : + City : Washington D.C. + Region : + Country : USA + - + Box : + Extended : The Daily Planet + Street : + Code : + City : Metropolis + Region : + Country : USA + home : + Box : + Extended : + Street : 344 Clinton Street + Code : + City : Metropolis + Region : + Country : USA + +# categories or tags +# format: +# Categories : single category +# or +# Categories : +# - category1 +# - category2 +Categories : + +# web pages +# may contain a string or a list of strings +Webpage : + +# private objects +# define your own private objects in the vcard section of your khard config file +# example: +# [vcard] +# private_objects = Jabber, Skype, Twitter +# these objects are stored with a leading "X-" before the object name in the +# vcard files. +# every entry may contain a string or a list of strings +Private : + +# notes +# may contain a string or a list of strings +# for multi-line notes use: +# Note : | +# line one +# line two +Note : + diff --git a/test/test_csv.py b/test/test_csv.py new file mode 100644 index 00000000..61156f8b --- /dev/null +++ b/test/test_csv.py @@ -0,0 +1,64 @@ +"""Tests for the csv submodule""" + +import os +import unittest +from unittest import mock + +from khard.contacts import Contact +from khard.csv import Parser + + +class TestCSVParser(unittest.TestCase): + """Tests the csv module and khard.contacts.Contact.from_dict().""" + def test_yaml_and_csv_produce_equivalent_contacts(self): + """Test that YAML and CSV with same data produce equivalent Contacts. + + Make one of the CSV files "jumbled" to verify that column order + doesn't matter to getting the right result. + """ + contacts_from_yaml = [] + for basename in ["batman.yaml", "superman.yaml", "lois_lane.yaml"]: + with open(os.path.join("test/fixture/csv", basename)) as f: + contact = Contact.from_yaml( + address_book=mock.Mock(path="foo-path"), + yaml=f.read(), + supported_private_objects=[], + version="3.0", + localize_dates=False + ) + contacts_from_yaml.append(contact) + + contacts_from_neat_csv = [] + with open("test/fixture/csv/neat.csv") as f: + for contact_data in Parser(f.read(), ","): + contact = Contact.from_dict( + address_book=mock.Mock(path="foo-path"), + data=contact_data, + supported_private_objects=[], + version="3.0", + localize_dates=False + ) + contacts_from_neat_csv.append(contact) + + contacts_from_jumbled_csv = [] + with open("test/fixture/csv/jumbled.csv") as f: + for contact_data in Parser(f.read(), ","): + contact = Contact.from_dict( + address_book=mock.Mock(path="foo-path"), + data=contact_data, + supported_private_objects=[], + version="3.0", + localize_dates=False + ) + contacts_from_jumbled_csv.append(contact) + + self.assertEqual(len(contacts_from_yaml), + len(contacts_from_neat_csv)) + self.assertEqual(len(contacts_from_neat_csv), + len(contacts_from_jumbled_csv)) + for i in range(0, len(contacts_from_yaml)): + with self.subTest(row=i+1): + self.assertEqual(contacts_from_yaml[i], + contacts_from_neat_csv[i]) + self.assertEqual(contacts_from_neat_csv[i], + contacts_from_jumbled_csv[i])