From da380a38c67a7a0b3efc957ccf20eefb4c1a0397 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 1 Apr 2026 09:11:45 +0200 Subject: [PATCH] fix: convert BDAY datetime.date to string before Pydantic validation pythonvCard4 parses vCard BDAY fields into datetime.date objects, but the Contact model expects Optional[str]. This caused a validation error that crashed the entire contact list. Convert at the client layer (consistent with the calendar client pattern) with a defensive check at the server mapping layer. Closes #672 Co-Authored-By: Claude Opus 4.6 (1M context) --- nextcloud_mcp_server/client/contacts.py | 5 ++- nextcloud_mcp_server/server/contacts.py | 5 ++- tests/unit/test_response_models.py | 42 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/nextcloud_mcp_server/client/contacts.py b/nextcloud_mcp_server/client/contacts.py index 8f959f66..a9a8e457 100644 --- a/nextcloud_mcp_server/client/contacts.py +++ b/nextcloud_mcp_server/client/contacts.py @@ -2,6 +2,7 @@ import logging import xml.etree.ElementTree as ET +from datetime import date from pythonvCard4.vcard import Contact @@ -272,7 +273,9 @@ class ContactsClient(BaseNextcloudClient): "contact": { "fullname": contact.fn, "nickname": contact.nickname, - "birthday": contact.bday, + "birthday": contact.bday.isoformat() + if isinstance(contact.bday, date) + else contact.bday, "email": contact.email, "tel": contact.tel, }, diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index a5e91da9..33254d35 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -1,4 +1,5 @@ import logging +from datetime import date from typing import Any from mcp.server.fastmcp import Context, FastMCP @@ -83,7 +84,9 @@ def _raw_contact_to_model(raw: dict) -> Contact: uid=raw["vcard_id"], fn=contact_info.get("fullname", ""), etag=raw.get("getetag"), - birthday=contact_info.get("birthday"), + birthday=contact_info["birthday"].isoformat() + if isinstance(contact_info.get("birthday"), date) + else contact_info.get("birthday"), emails=emails, phones=phones, custom_fields=custom_fields, diff --git a/tests/unit/test_response_models.py b/tests/unit/test_response_models.py index da0fa383..672e730f 100644 --- a/tests/unit/test_response_models.py +++ b/tests/unit/test_response_models.py @@ -1,5 +1,7 @@ """Unit tests for Pydantic response models.""" +from datetime import date + import pytest from nextcloud_mcp_server.models.contacts import ( @@ -310,6 +312,46 @@ def test_contact_mapping_preserves_email_birthday_nickname(): assert contact.custom_fields["nickname"] == "JD" +@pytest.mark.unit +def test_contact_mapping_birthday_datetime_date_object(): + """Test that a datetime.date birthday is converted to ISO string. + + Regression test for GH #672: pythonvCard4 returns datetime.date objects + for BDAY fields, which caused Pydantic validation errors. + """ + raw_contact = { + "vcard_id": "bday-date-1", + "contact": { + "fullname": "Date Object", + "birthday": date(1990, 5, 15), + }, + } + + contact = _map_contact(raw_contact) + + assert contact.birthday == "1990-05-15" + + +@pytest.mark.unit +def test_contact_mapping_birthday_apple_unknown_year(): + """Test Apple/iOS unknown-year birthday convention (year 1604). + + Apple contacts use BDAY;VALUE=DATE:16040808 when the birth year is unknown. + pythonvCard4 parses this as datetime.date(1604, 8, 8). + """ + raw_contact = { + "vcard_id": "bday-apple-1", + "contact": { + "fullname": "Apple Contact", + "birthday": date(1604, 8, 8), + }, + } + + contact = _map_contact(raw_contact) + + assert contact.birthday == "1604-08-08" + + @pytest.mark.unit def test_contact_mapping_multiple_emails(): """Test that multiple emails are mapped correctly."""