Skip to content

Commit a83c749

Browse files
authored
Merge pull request #336 from lucc/nullable-properties
Nullable properties
2 parents c6a2575 + a756186 commit a83c749

File tree

4 files changed

+144
-81
lines changed

4 files changed

+144
-81
lines changed

khard/contacts.py

Lines changed: 88 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
logger = logging.getLogger(__name__)
4141
T = TypeVar("T")
42+
LabeledStrs = list[Union[str, dict[str, str]]]
4243

4344

4445
@overload
@@ -112,30 +113,35 @@ def __init__(self, vcard: vobject.base.Component,
112113
def __str__(self) -> str:
113114
return self.formatted_name
114115

115-
def get_first(self, property: str, default: str = "") -> str:
116+
@overload
117+
def get_first(self, property: Literal["n"]) -> Optional[vobject.vcard.Name]: ...
118+
@overload
119+
def get_first(self, property: Literal["adr"]) -> Optional[vobject.vcard.Address]: ...
120+
@overload
121+
def get_first(self, property: str) -> Optional[str]: ...
122+
def get_first(self, property: str) -> Union[None, str, vobject.vcard.Name,
123+
vobject.vcard.Address]:
116124
"""Get a property from the underlying vCard.
117125
118126
This method should only be called for properties with cardinality \\*1
119127
(zero or one). Otherwise only the first value will be returned. If the
120-
property is not present a default will be returned.
128+
property is not present None will be retuned.
121129
122130
The type annotation for the return value is str but this is not
123131
enforced so it is up to the caller to make sure to only call this
124132
method for properties where the underlying vobject library returns a
125133
str.
126134
127135
:param property: the field value to get
128-
:param default: the value to return if the vCard does not have this
129-
property
130-
:returns: the property value or the default
136+
:returns: the property value or None
131137
"""
132138
try:
133139
return getattr(self.vcard, property).value
134140
except AttributeError:
135-
return default
141+
return None
136142

137-
def _get_multi_property(self, name: str) -> list:
138-
"""Get a vCard property that can exist more than once.
143+
def get_all(self, name: str) -> list:
144+
"""Get all values of the given vCard property.
139145
140146
It does not matter what the individual vcard properties store as their
141147
value. This function returns them untouched inside an aggregating
@@ -147,14 +153,12 @@ def _get_multi_property(self, name: str) -> list:
147153
:param name: the name of the property (should be UPPER case)
148154
:returns: the values from all occurrences of the named property
149155
"""
150-
values = []
151-
for child in self.vcard.getChildren():
152-
if child.name == name:
153-
ablabel = self._get_ablabel(child)
154-
if ablabel:
155-
values.append({ablabel: child.value})
156-
else:
157-
values.append(child.value)
156+
try:
157+
values = getattr(self.vcard, f"{name.lower()}_list")
158+
except AttributeError:
159+
return []
160+
values = [{label: item.value} if (label := self._get_ablabel(item))
161+
else item.value for item in values]
158162
return sorted(values, key=multi_property_key)
159163

160164
def _delete_vcard_object(self, name: str) -> None:
@@ -259,7 +263,7 @@ def _get_types_for_vcard_object(self, object: vobject.base.ContentLine,
259263
return [default_type]
260264

261265
@property
262-
def version(self) -> str:
266+
def version(self) -> Optional[str]:
263267
return self.get_first("version")
264268

265269
@version.setter
@@ -274,7 +278,7 @@ def version(self, value: str) -> None:
274278
version.value = convert_to_vcard("version", value, ObjectType.str)
275279

276280
@property
277-
def uid(self) -> str:
281+
def uid(self) -> Optional[str]:
278282
return self.get_first("uid")
279283

280284
@uid.setter
@@ -470,7 +474,7 @@ def _prepare_birthday_value(self, date: Date) -> tuple[Optional[str],
470474

471475
@property
472476
def kind(self) -> str:
473-
return self.get_first(self._kind_attribute_name().lower(), self._default_kind)
477+
return self.get_first(self._kind_attribute_name().lower()) or self._default_kind
474478

475479
@kind.setter
476480
def kind(self, value: str) -> None:
@@ -487,10 +491,15 @@ def _kind_attribute_name(self) -> str:
487491

488492
@property
489493
def formatted_name(self) -> str:
490-
return self.get_first("fn")
494+
fn = self.get_first("fn")
495+
if fn:
496+
return fn
497+
self.formatted_name = ""
498+
return self.get_first("fn") or ""
491499

492500
@formatted_name.setter
493501
def formatted_name(self, value: str) -> None:
502+
# TODO cardinality 1*
494503
"""Set the FN field to the new value.
495504
496505
All previously existing FN fields are deleted. Version 4 of the specs
@@ -522,10 +531,9 @@ def _get_names_part(self, part: str) -> list[str]:
522531
the_list = getattr(self.vcard.n.value, part)
523532
except AttributeError:
524533
return []
525-
else:
526-
# check if list only contains empty strings
527-
if not ''.join(the_list):
528-
return []
534+
# check if list only contains empty strings
535+
if not ''.join(the_list):
536+
return []
529537
return the_list if isinstance(the_list, list) else [the_list]
530538

531539
def _get_name_prefixes(self) -> list[str]:
@@ -573,12 +581,16 @@ def get_last_name_first_name(self) -> str:
573581
return self.formatted_name
574582

575583
@property
576-
def first_name(self) -> str:
577-
return list_to_string(self._get_first_names(), " ")
584+
def first_name(self) -> Optional[str]:
585+
if parts := self._get_first_names():
586+
return list_to_string(parts, " ")
587+
return None
578588

579589
@property
580-
def last_name(self) -> str:
581-
return list_to_string(self._get_last_names(), " ")
590+
def last_name(self) -> Optional[str]:
591+
if parts := self._get_last_names():
592+
return list_to_string(parts, " ")
593+
return None
582594

583595
def _add_name(self, prefix: StrList, first_name: StrList,
584596
additional_name: StrList, last_name: StrList,
@@ -605,7 +617,7 @@ def organisations(self) -> list[Union[list[str], dict[str, list[str]]]]:
605617
"""
606618
:returns: list of organisations, sorted alphabetically
607619
"""
608-
return self._get_multi_property("ORG")
620+
return self.get_all("org")
609621

610622
def _add_organisation(self, organisation: StrList, label: Optional[str] = None) -> None:
611623
"""Add one ORG entry to the underlying vcard
@@ -628,48 +640,47 @@ def _add_organisation(self, organisation: StrList, label: Optional[str] = None)
628640
showas_obj.value = "COMPANY"
629641

630642
@property
631-
def titles(self) -> list[Union[str, dict[str, str]]]:
632-
return self._get_multi_property("TITLE")
643+
def titles(self) -> LabeledStrs:
644+
return self.get_all("title")
633645

634646
def _add_title(self, title: str, label: Optional[str] = None) -> None:
635647
self._add_labelled_property("title", title, label, True)
636648

637649
@property
638-
def roles(self) -> list[Union[str, dict[str, str]]]:
639-
return self._get_multi_property("ROLE")
650+
def roles(self) -> LabeledStrs:
651+
return self.get_all("role")
640652

641653
def _add_role(self, role: str, label: Optional[str] = None) -> None:
642654
self._add_labelled_property("role", role, label, True)
643655

644656
@property
645-
def nicknames(self) -> list[Union[str, dict[str, str]]]:
646-
return self._get_multi_property("NICKNAME")
657+
def nicknames(self) -> LabeledStrs:
658+
return self.get_all("nickname")
647659

648660
def _add_nickname(self, nickname: str, label: Optional[str] = None) -> None:
649661
self._add_labelled_property("nickname", nickname, label, True)
650662

651663
@property
652-
def notes(self) -> list[Union[str, dict[str, str]]]:
653-
return self._get_multi_property("NOTE")
664+
def notes(self) -> LabeledStrs:
665+
return self.get_all("note")
654666

655667
def _add_note(self, note: str, label: Optional[str] = None) -> None:
656668
self._add_labelled_property("note", note, label, True)
657669

658670
@property
659-
def webpages(self) -> list[Union[str, dict[str, str]]]:
660-
return self._get_multi_property("URL")
671+
def webpages(self) -> LabeledStrs:
672+
return self.get_all("url")
661673

662674
def _add_webpage(self, webpage: str, label: Optional[str] = None) -> None:
663675
self._add_labelled_property("url", webpage, label, True)
664676

665677
@property
666678
def categories(self) -> Union[list[str], list[list[str]]]:
667-
category_list = []
668-
for child in self.vcard.getChildren():
669-
if child.name == "CATEGORIES":
670-
value = child.value
671-
category_list.append(
672-
value if isinstance(value, list) else [value])
679+
category_list = self.get_all("categories")
680+
if not category_list:
681+
return category_list
682+
category_list = [value if isinstance(value, list) else [value] for
683+
value in category_list]
673684
if len(category_list) == 1:
674685
return category_list[0]
675686
return sorted(category_list)
@@ -754,13 +765,16 @@ def emails(self) -> dict[str, list[str]]:
754765
:returns: dict of type and email address list
755766
"""
756767
email_dict: dict[str, list[str]] = {}
757-
for child in self.vcard.getChildren():
758-
if child.name == "EMAIL":
759-
type = list_to_string(
760-
self._get_types_for_vcard_object(child, "internet"), ", ")
761-
if type not in email_dict:
762-
email_dict[type] = []
763-
email_dict[type].append(child.value)
768+
try:
769+
emails = self.vcard.email_list
770+
except AttributeError:
771+
return {}
772+
for child in emails:
773+
type = list_to_string(
774+
self._get_types_for_vcard_object(child, "internet"), ", ")
775+
if type not in email_dict:
776+
email_dict[type] = []
777+
email_dict[type].append(child.value)
764778
# sort email address lists
765779
for email_list in email_dict.values():
766780
email_list.sort()
@@ -805,19 +819,22 @@ def post_addresses(self) -> dict[str, list[PostAddress]]:
805819
:returns: dict of type and post address list
806820
"""
807821
post_adr_dict: dict[str, list[PostAddress]] = {}
808-
for child in self.vcard.getChildren():
809-
if child.name == "ADR":
810-
type = list_to_string(self._get_types_for_vcard_object(
811-
child, "home"), ", ")
812-
if type not in post_adr_dict:
813-
post_adr_dict[type] = []
814-
post_adr_dict[type].append({"box": child.value.box,
815-
"extended": child.value.extended,
816-
"street": child.value.street,
817-
"code": child.value.code,
818-
"city": child.value.city,
819-
"region": child.value.region,
820-
"country": child.value.country})
822+
try:
823+
addresses = self.vcard.adr_list
824+
except AttributeError:
825+
return {}
826+
for child in addresses:
827+
type = list_to_string(self._get_types_for_vcard_object(
828+
child, "home"), ", ")
829+
if type not in post_adr_dict:
830+
post_adr_dict[type] = []
831+
post_adr_dict[type].append({"box": child.value.box,
832+
"extended": child.value.extended,
833+
"street": child.value.street,
834+
"code": child.value.code,
835+
"city": child.value.city,
836+
"region": child.value.region,
837+
"country": child.value.country})
821838
# sort post address lists
822839
for post_adr_list in post_adr_dict.values():
823840
post_adr_list.sort(key=lambda x: (
@@ -930,9 +947,9 @@ def __init__(self, vcard: vobject.base.Component,
930947
# getters and setters
931948
#####################
932949

933-
def _get_private_objects(self) -> dict[str, list[Union[str, dict[str, str]]]]:
950+
def _get_private_objects(self) -> dict[str, LabeledStrs]:
934951
supported = [x.lower() for x in self.supported_private_objects]
935-
private_objects: dict[str, list[Union[str, dict[str, str]]]] = {}
952+
private_objects: dict[str, LabeledStrs] = {}
936953
for child in self.vcard.getChildren():
937954
lower = child.name.lower()
938955
if lower.startswith("x-") and lower[2:] in supported:
@@ -1303,9 +1320,11 @@ def to_yaml(self) -> str:
13031320
"Note": self.notes,
13041321
"Webpage": self.webpages,
13051322
"Anniversary":
1306-
helpers.yaml_anniversary(self.anniversary, self.version),
1323+
helpers.yaml_anniversary(self.anniversary, self.version or
1324+
self._default_version),
13071325
"Birthday":
1308-
helpers.yaml_anniversary(self.birthday, self.version),
1326+
helpers.yaml_anniversary(self.birthday, self.version or
1327+
self._default_version),
13091328
"Address": helpers.yaml_addresses(
13101329
self.post_addresses, ["Box", "Extended", "Street", "Code",
13111330
"City", "Region", "Country"], defaults=["home"])

khard/khard.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ def list_contacts(vcard_list: list[Contact], fields: Iterable[str] = (),
210210
row.append(formatter.get_special_field(vcard, field))
211211
elif field == 'uid':
212212
if parsable:
213-
row.append(vcard.uid)
214-
elif abook_collection.get_short_uid(vcard.uid):
215-
row.append(abook_collection.get_short_uid(vcard.uid))
213+
row.append(vcard.uid or "")
214+
elif uid := abook_collection.get_short_uid(vcard.uid or ""):
215+
row.append(uid)
216216
else:
217217
row.append("")
218218
else:

test/test_query.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,28 @@ def test_and_queries_match_after_sorting(self):
105105

106106

107107
class TestFieldQuery(unittest.TestCase):
108-
@unittest.expectedFailure
109-
def test_empty_field_values_match_if_the_field_is_present(self):
110-
# This test currently fails because the Contact class has all
111-
# attributes set because they are properties. So the test in the query
112-
# class if an attribute is present never fails.
108+
def test_empty_field_values_match_if_sstring_field_is_present(self):
113109
uid = "Some Test Uid"
114110
vcard1 = TestContact(uid=uid)
115111
vcard2 = TestContact()
116112
query = FieldQuery("uid", "")
117113
self.assertTrue(query.match(vcard1))
118114
self.assertFalse(query.match(vcard2))
119115

116+
def test_empty_field_values_match_if_list_field_is_present(self):
117+
vcard1 = TestContact(categories=["foo", "bar"])
118+
vcard2 = TestContact()
119+
query = FieldQuery("categories", "")
120+
self.assertTrue(query.match(vcard1))
121+
self.assertFalse(query.match(vcard2))
122+
123+
def test_empty_field_values_match_if_dict_field_is_present(self):
124+
query = FieldQuery("emails", "")
125+
vcard = TestContact()
126+
self.assertFalse(query.match(vcard))
127+
vcard.add_email("home", "[email protected]")
128+
self.assertTrue(query.match(vcard))
129+
120130
def test_empty_field_values_fails_if_the_field_is_absent(self):
121131
vcard = TestContact()
122132
query = FieldQuery("emails", "")

0 commit comments

Comments
 (0)