Skip to content

Commit e2283ff

Browse files
cbcoutinhoclaude
andcommitted
refactor(contacts): address PR #719 follow-up review
Pulls the remaining review feedback into one commit: - Remove the double _normalize_contact_data call: the helper now assumes canonical keys, and create_contact normalises before calling it (update_contact already did). Docstring states the invariant. - Drop phone/organization from _SUPPORTED_CONTACT_KEYS; they never reach the unknown-key check post-normalisation. - Tighten generics to dict[str, Any] / list[str] across helpers and ContactsClient signatures. - Comment both URL-merge sites noting only the first URL is written. - Log a warning when fn is missing from contact_data. - Test coverage for _wrap_contact_field dropping value-less dicts and for the fn-missing warning; _vcard helper now mirrors the real call chain (normalise → build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee1465c commit e2283ff

2 files changed

Lines changed: 77 additions & 18 deletions

File tree

nextcloud_mcp_server/client/contacts.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import xml.etree.ElementTree as ET
55
from datetime import date
6+
from typing import Any
67

78
from pythonvCard4.vcard import Contact
89

@@ -11,16 +12,15 @@
1112
logger = logging.getLogger(__name__)
1213

1314

14-
# Canonical keys that _build_contact_from_data consumes. Aliases (``phone``, ``organization``)
15-
# are normalised to their canonical form by _normalize_contact_data before lookup.
15+
# Canonical keys accepted by _build_contact_from_data. Callers normalise aliases
16+
# (``phone``→``tel``, ``organization``→``org``) via _normalize_contact_data beforehand
17+
# so the set never needs to list them.
1618
_SUPPORTED_CONTACT_KEYS = frozenset(
1719
{
1820
"fn",
1921
"email",
2022
"tel",
21-
"phone",
2223
"org",
23-
"organization",
2424
"note",
2525
"title",
2626
"nickname",
@@ -31,7 +31,7 @@
3131
)
3232

3333

34-
def _normalize_contact_data(contact_data: dict) -> dict:
34+
def _normalize_contact_data(contact_data: dict[str, Any]) -> dict[str, Any]:
3535
"""Map documented aliases to canonical keys.
3636
3737
``phone`` → ``tel``, ``organization`` → ``org``. The canonical key wins if both
@@ -50,16 +50,19 @@ def _normalize_contact_data(contact_data: dict) -> dict:
5050
return normalised
5151

5252

53-
def _wrap_contact_field(value: str | dict | list | None) -> list[dict]:
53+
def _wrap_contact_field(
54+
value: str | dict[str, Any] | list[str | dict[str, Any]] | None,
55+
) -> list[dict[str, Any]]:
5456
"""Normalize an email/tel input into pythonvCard4's list-of-dicts shape.
5557
5658
Accepts a plain string, a dict already in ``{value, type}`` form, or a list of
57-
either. Empty strings are dropped. Always returns a list (possibly empty).
59+
either. Empty strings and dicts without a ``value`` key are dropped. Always
60+
returns a list (possibly empty).
5861
"""
5962
if value is None or value == "":
6063
return []
6164
items = value if isinstance(value, list) else [value]
62-
out: list[dict] = []
65+
out: list[dict[str, Any]] = []
6366
for item in items:
6467
if isinstance(item, dict) and item.get("value"):
6568
types = item.get("type") or ["HOME"]
@@ -69,7 +72,7 @@ def _wrap_contact_field(value: str | dict | list | None) -> list[dict]:
6972
return out
7073

7174

72-
def _as_str_list(value: str | list) -> list[str]:
75+
def _as_str_list(value: str | list[str]) -> list[str]:
7376
"""Wrap a bare string in a list. Does NOT split on commas.
7477
7578
Used for ORG/NICKNAME/URL where commas are part of the value (e.g.
@@ -79,7 +82,7 @@ def _as_str_list(value: str | list) -> list[str]:
7982
return value if isinstance(value, list) else [value]
8083

8184

82-
def _split_categories(value: str | list) -> list[str]:
85+
def _split_categories(value: str | list[str]) -> list[str]:
8386
"""Normalise CATEGORIES input: a comma-separated string is split into a list.
8487
8588
Unlike ORG/NICKNAME, CATEGORIES is canonically comma-separated in vCards
@@ -92,16 +95,25 @@ def _split_categories(value: str | list) -> list[str]:
9295
return [v.strip() for v in value.split(",") if v.strip()]
9396

9497

95-
def _build_contact_from_data(contact_data: dict, uid: str) -> Contact:
98+
def _build_contact_from_data(contact_data: dict[str, Any], uid: str) -> Contact:
9699
"""Build a pythonvCard4 Contact from an MCP ``contact_data`` dict.
97100
98101
Maps every key documented on ``nc_contacts_create_contact`` onto the underlying
99102
library, normalising shapes (list/str) to avoid pythonvCard4's char-by-char
100103
iteration of bare strings — see issue #716.
104+
105+
Callers must pre-normalise aliases via ``_normalize_contact_data`` before
106+
invoking this helper; it assumes canonical keys only.
101107
"""
102-
data = _normalize_contact_data(contact_data)
108+
data = contact_data
109+
110+
if not data.get("fn"):
111+
logger.warning(
112+
"contact_data missing required 'fn' field; pythonvCard4 may reject or "
113+
"produce an invalid vCard"
114+
)
103115

104-
kwargs: dict = {"fn": data.get("fn"), "uid": uid}
116+
kwargs: dict[str, Any] = {"fn": data.get("fn"), "uid": uid}
105117

106118
emails = _wrap_contact_field(data.get("email"))
107119
if emails:
@@ -259,11 +271,15 @@ async def delete_addressbook(self, *, name: str):
259271
url = f"{carddav_path}/{name}/"
260272
await self._make_request("DELETE", url)
261273

262-
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
274+
async def create_contact(
275+
self, *, addressbook: str, uid: str, contact_data: dict[str, Any]
276+
):
263277
"""Create a new contact."""
264278
carddav_path = self._get_carddav_base_path()
265279
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
266280

281+
# Normalise aliases here so the helper's invariant (canonical keys only) holds.
282+
contact_data = _normalize_contact_data(contact_data)
267283
vcard = _build_contact_from_data(contact_data, uid).to_vcard()
268284

269285
headers = {
@@ -280,7 +296,12 @@ async def delete_contact(self, *, addressbook: str, uid: str):
280296
await self._make_request("DELETE", url)
281297

282298
async def update_contact(
283-
self, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
299+
self,
300+
*,
301+
addressbook: str,
302+
uid: str,
303+
contact_data: dict[str, Any],
304+
etag: str = "",
284305
):
285306
"""Update an existing contact while preserving all existing properties."""
286307
carddav_path = self._get_carddav_base_path()
@@ -429,7 +450,7 @@ async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]:
429450
raise
430451

431452
def _merge_vcard_properties(
432-
self, raw_vcard: str, contact_data: dict, uid: str
453+
self, raw_vcard: str, contact_data: dict[str, Any], uid: str
433454
) -> str:
434455
"""Merge new contact data into existing raw vCard while preserving all properties."""
435456
try:
@@ -521,6 +542,9 @@ def _merge_vcard_properties(
521542
elif property_name == "URL" and "url" in contact_data:
522543
if "url" not in updated_properties:
523544
url_value = contact_data["url"]
545+
# Only the first URL from a list is written; multi-URL
546+
# contacts are rare and this text merge doesn't attempt
547+
# position-stable mapping to existing URL lines.
524548
if isinstance(url_value, list):
525549
url_value = url_value[0] if url_value else ""
526550
if url_value:
@@ -561,6 +585,8 @@ def _merge_vcard_properties(
561585
elif key == "title":
562586
updated_lines.append(f"TITLE:{value}")
563587
elif key == "url":
588+
# Only the first URL is written on add-new; see note in the
589+
# update-existing branch above.
564590
url_value = (
565591
value[0] if isinstance(value, list) and value else value
566592
)

tests/unit/client/test_contacts.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@
1212
from nextcloud_mcp_server.client.contacts import (
1313
_build_contact_from_data,
1414
_normalize_contact_data,
15+
_wrap_contact_field,
1516
)
1617

1718
pytestmark = pytest.mark.unit
1819

1920

2021
def _vcard(**kwargs) -> str:
21-
"""Build a vCard from ``contact_data`` with a fixed uid, return the serialised text."""
22-
return _build_contact_from_data(kwargs, uid="unit-test-uid").to_vcard()
22+
"""Build a vCard from ``contact_data`` with a fixed uid, return the serialised text.
23+
24+
Mirrors ``create_contact``'s real call chain: normalise aliases first, then hand
25+
canonical keys to ``_build_contact_from_data``.
26+
"""
27+
data = _normalize_contact_data(kwargs)
28+
return _build_contact_from_data(data, uid="unit-test-uid").to_vcard()
2329

2430

2531
def test_issue_716_minimal_payload_keeps_all_fields():
@@ -154,6 +160,33 @@ def test_dict_form_email_preserves_custom_type():
154160
assert "EMAIL;TYPE=WORK:work@example.com" in vcard
155161

156162

163+
def test_wrap_field_dict_without_value_is_dropped():
164+
"""Dict inputs lacking the ``value`` key are silently dropped so malformed
165+
payloads don't emit an EMAIL/TEL line pointing at nothing.
166+
"""
167+
assert _wrap_contact_field({"type": ["WORK"]}) == []
168+
# Mixed list: the valid entry survives, the value-less dict is omitted.
169+
out = _wrap_contact_field(
170+
[{"value": "ok@example.com", "type": ["WORK"]}, {"type": ["HOME"]}]
171+
)
172+
assert out == [{"value": "ok@example.com", "type": ["WORK"]}]
173+
174+
175+
def test_missing_fn_logs_warning(caplog):
176+
"""A missing ``fn`` should log a warning so operators notice malformed payloads."""
177+
import logging
178+
179+
with caplog.at_level(
180+
logging.WARNING, logger="nextcloud_mcp_server.client.contacts"
181+
):
182+
try:
183+
_build_contact_from_data({"email": "x@example.com"}, uid="no-fn-uid")
184+
except Exception:
185+
# pythonvCard4 may raise on missing fn; we only care about the warning log.
186+
pass
187+
assert any("fn" in r.message.lower() for r in caplog.records)
188+
189+
157190
class TestNormalizeContactData:
158191
"""Direct tests for the alias helper — it's load-bearing for update_contact too."""
159192

0 commit comments

Comments
 (0)