From 5225f1e2df64e8b9370ed5cf2d8a79fe2454dd81 Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 13:23:49 -0800 Subject: [PATCH 1/6] Allow creating contacts from dict, not just YAML This will make it possible to create contacts from data formats other than YAML without rendering the data as a YAML string. --- khard/contacts.py | 54 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/khard/contacts.py b/khard/contacts.py index 4d05fa9..c0ab2a8 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 From cee7a84a74643b9cb52063979c09d4d35375560f Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 13:31:44 -0800 Subject: [PATCH 2/6] Initial commit of CSV parsing module This module reads CSV files and returns data that can be read by the 'khard.contacts' module. The module will be used to create new contacts from CSV. --- khard/csv.py | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 khard/csv.py diff --git a/khard/csv.py b/khard/csv.py new file mode 100644 index 0000000..addb247 --- /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 From fd605bf2320ab3002b10c59d100db079548b0133 Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 13:35:14 -0800 Subject: [PATCH 3/6] Print a CSV template with `khard template --format csv` '--format' specifies the output format, either 'yaml' or 'csl'. YAML is still the default output format for 'khard template'. The CSV template will be used when importing contacts from CSV. Just as a YAML template is opened in the user's editor when 'khard new' is invoked without any input, a CSV template will be opened in the user's editor when 'khard new --format csv' is invoked without any input. (This will not be terribly useful, since CSV is not very pleasant to edit in a text editor, but it will be consistent with the current behavior of 'khard new'). --- khard/cli.py | 11 ++++++++++- khard/data/template.csv | 1 + khard/helpers/__init__.py | 15 +++++++++++++++ khard/khard.py | 7 +++++-- 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 khard/data/template.csv diff --git a/khard/cli.py b/khard/cli.py index 55f98a7..fd22b8b 100644 --- a/khard/cli.py +++ b/khard/cli.py @@ -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"), diff --git a/khard/data/template.csv b/khard/data/template.csv new file mode 100644 index 0000000..3f1ae74 --- /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 e579fcc..eb33ef0 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/khard.py b/khard/khard.py index f0c2bcb..ccfaa9d 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -1101,12 +1101,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" From 1dabd1119b8e6156a1fae9bdfc18afe16faa83a4 Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 13:42:30 -0800 Subject: [PATCH 4/6] Add function to create contacts by editing CSV template This is not particularly useful on its own, but it will be used as a fallback when importing contacts from CSV, in the event that the user doesn't supply any input (i.e., like 'khard new' does). --- khard/helpers/interactive.py | 52 +++++++++++++++++++++++++++++++++--- khard/khard.py | 18 +++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/khard/helpers/interactive.py b/khard/helpers/interactive.py index 2a95d58..4a9f7cd 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 ccfaa9d..3c34e4d 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -65,6 +65,24 @@ 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 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 From 3fc56aec3e4da5508cfeb4a0970d81dc80eb2506 Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 13:45:10 -0800 Subject: [PATCH 5/6] Import contacts from CSV with `khard new --format csv` --- khard/cli.py | 12 ++++++-- khard/khard.py | 74 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/khard/cli.py b/khard/cli.py index fd22b8b..ba1688c 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=[], @@ -268,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/khard.py b/khard/khard.py index 3c34e4d..3d5c92c 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 @@ -83,6 +84,42 @@ def create_new_contacts(address_book: VdirAddressBook, delimiter: str) -> None: 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 @@ -385,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) def add_email_to_contact(name: str, email_address: str, @@ -1199,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, From 043a476c4b8b9cd65ba3a5f6bc3d88ef45fcc216 Mon Sep 17 00:00:00 2001 From: Russell Wright Helder Date: Mon, 19 Jan 2026 19:49:20 -0800 Subject: [PATCH 6/6] Initial commit of unit test for CSV parsing submodule Check whether or not YAML files and CSV files containing the same data produce equivalent Contact objects. Make one of the CSV files 'jumbled' to show that column order doesn't matter to getting the right result. --- khard/khard.py | 2 +- test/fixture/csv/batman.yaml | 161 ++++++++++++++++++++++++++++++++ test/fixture/csv/jumbled.csv | 4 + test/fixture/csv/lois_lane.yaml | 140 +++++++++++++++++++++++++++ test/fixture/csv/neat.csv | 4 + test/fixture/csv/superman.yaml | 161 ++++++++++++++++++++++++++++++++ test/test_csv.py | 64 +++++++++++++ 7 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 test/fixture/csv/batman.yaml create mode 100644 test/fixture/csv/jumbled.csv create mode 100644 test/fixture/csv/lois_lane.yaml create mode 100644 test/fixture/csv/neat.csv create mode 100644 test/fixture/csv/superman.yaml create mode 100644 test/test_csv.py diff --git a/khard/khard.py b/khard/khard.py index 3d5c92c..cc450c5 100644 --- a/khard/khard.py +++ b/khard/khard.py @@ -451,7 +451,7 @@ def new_subcommand(abooks: AddressBookCollection, fmt: str, delimiter: str, if fmt == 'yaml': create_new_contact(abook) elif fmt == 'csv': - create_new_contacts(abook) + create_new_contacts(abook, delimiter) def add_email_to_contact(name: str, email_address: str, diff --git a/test/fixture/csv/batman.yaml b/test/fixture/csv/batman.yaml new file mode 100644 index 0000000..a5b9297 --- /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 0000000..d0f7c21 --- /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 0000000..07977cc --- /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 0000000..0fb3ab8 --- /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 0000000..f546d05 --- /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 0000000..61156f8 --- /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])