From c1fac9688b9ef84a781744222e5e203b390df482 Mon Sep 17 00:00:00 2001 From: Harini Date: Mon, 17 Aug 2026 11:19:16 +0530 Subject: [PATCH 1/2] fix: make uid: search match by prefix instead of substring khard list displays a short unique UID prefix for each contact, but uid:xxx searched by substring so uid:a would match both "aaabbbb" and "bbbaaaa". Introduce UidQuery which uses startswith so the displayed prefix can be used directly to select a contact unambiguously. Fixes #327 --- khard/query.py | 34 +++++++++++++++++++++++++-- test/test_query.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/khard/query.py b/khard/query.py index 97fe2073..cebf55ed 100644 --- a/khard/query.py +++ b/khard/query.py @@ -167,7 +167,7 @@ def __str__(self) -> str: class AndQuery(Query): - """A query to combine multiple queries with "and".""" + """A query to combine multiple queries with \"and\".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -198,7 +198,7 @@ def __str__(self) -> str: class OrQuery(Query): - """A query to combine multiple queries with "or".""" + """A query to combine multiple queries with \"or\".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -254,6 +254,34 @@ def __str__(self) -> str: return 'name:{}'.format(self._term) +class UidQuery(FieldQuery): + + """A query to match the UID field using prefix matching. + + khard list shows a short unique UID prefix for each contact. This + query matches only contacts whose UID starts with the given term so + that the displayed prefix selects exactly the expected contact. + """ + + def __init__(self, value: str) -> None: + super().__init__("uid", value) + + def _match_union(self, value: "str | datetime | list | dict[str, Any]" + ) -> bool: + if isinstance(value, str): + return value.lower().startswith(self._term) + return super()._match_union(value) + + def __eq__(self, other: object) -> bool: + return isinstance(other, UidQuery) and self._term == other._term + + def __hash__(self) -> int: + return hash((UidQuery, self._term)) + + def __str__(self) -> str: + return 'uid:{}'.format(self._term) + + class PhoneNumberQuery(FieldQuery): """A special query to match against phone numbers.""" @@ -353,6 +381,8 @@ def parse(string: str) -> TermQuery | FieldQuery: if kind.startswith(term.lower()): return FieldQuery(field, kind) return TermQuery(string) + if field == "uid": + return UidQuery(term) if field in contacts.Contact.get_properties(): return FieldQuery(field, term) return TermQuery(string) diff --git a/test/test_query.py b/test/test_query.py index 6b0133e6..9a77104f 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -8,6 +8,7 @@ NullQuery, OrQuery, TermQuery, + UidQuery, parse, ) @@ -203,6 +204,52 @@ def test_kind_query_with_explicit_mismatch(self): self.assertFalse(query.match(contact)) +class TestUidQuery(unittest.TestCase): + def setUp(self): + self.uid_a = "aaabbbb" + self.uid_b = "bbbaaaa" + self.contact_a = TestContact(uid=self.uid_a) + self.contact_b = TestContact(uid=self.uid_b) + + def test_matches_uid_by_prefix(self): + q = UidQuery("a") + self.assertTrue(q.match(self.contact_a)) + + def test_does_not_match_uid_with_term_as_substring_only(self): + # "a" is a substring of "bbbaaaa" but not a prefix + q = UidQuery("a") + self.assertFalse(q.match(self.contact_b)) + + def test_matches_full_uid(self): + q = UidQuery(self.uid_a) + self.assertTrue(q.match(self.contact_a)) + self.assertFalse(q.match(self.contact_b)) + + def test_matching_is_case_insensitive(self): + q = UidQuery("AAA") + self.assertTrue(q.match(self.contact_a)) + self.assertFalse(q.match(self.contact_b)) + + def test_empty_term_matches_any_contact_with_uid_set(self): + q = UidQuery("") + self.assertTrue(q.match(self.contact_a)) + self.assertTrue(q.match(self.contact_b)) + + def test_empty_term_does_not_match_contact_without_uid(self): + q = UidQuery("") + self.assertFalse(q.match(TestContact())) + + def test_shared_prefix_can_be_disambiguated(self): + uid_a = "aaabbbb" + uid_a2 = "aaacccc" + contact_a = TestContact(uid=uid_a) + contact_a2 = TestContact(uid=uid_a2) + self.assertTrue(UidQuery("aaab").match(contact_a)) + self.assertFalse(UidQuery("aaab").match(contact_a2)) + self.assertFalse(UidQuery("aaac").match(contact_a)) + self.assertTrue(UidQuery("aaac").match(contact_a2)) + + class TestNameQuery(unittest.TestCase): def test_matches_formatted_name_field(self): vcard = load_contact("minimal.vcf") @@ -271,3 +318,13 @@ def test_kind_queries_only_need_a_substring_of_the_enum(self): self.assertEqual(parse("kind:i"), FieldQuery("kind", "individual")) self.assertEqual(parse("kind:org"), FieldQuery("kind", "org")) self.assertEqual(parse("kind:o"), FieldQuery("kind", "org")) + + def test_uid_query_creates_uid_query_instance(self): + actual = parse("uid:abc123") + expected = UidQuery("abc123") + self.assertEqual(actual, expected) + + def test_uid_query_is_not_a_plain_field_query(self): + actual = parse("uid:abc123") + self.assertIsInstance(actual, UidQuery) + self.assertNotIsInstance(actual, FieldQuery) From 8df51e1577f85e73387922f3c0a0966f6f3fe119 Mon Sep 17 00:00:00 2001 From: Harini Date: Mon, 17 Aug 2026 11:20:01 +0530 Subject: [PATCH 2/2] test: fix uid query type assertion UidQuery is a subclass of FieldQuery so isinstance always returns True; use type() equality to assert that parse() returns a UidQuery specifically. --- test/test_query.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/test_query.py b/test/test_query.py index 9a77104f..2f434d46 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -319,12 +319,8 @@ def test_kind_queries_only_need_a_substring_of_the_enum(self): self.assertEqual(parse("kind:org"), FieldQuery("kind", "org")) self.assertEqual(parse("kind:o"), FieldQuery("kind", "org")) - def test_uid_query_creates_uid_query_instance(self): + def test_uid_field_creates_uid_query_instance(self): actual = parse("uid:abc123") expected = UidQuery("abc123") self.assertEqual(actual, expected) - - def test_uid_query_is_not_a_plain_field_query(self): - actual = parse("uid:abc123") - self.assertIsInstance(actual, UidQuery) - self.assertNotIsInstance(actual, FieldQuery) + self.assertEqual(type(actual), UidQuery)