From 7fa6be3ac67765c08cde2b8f24a8ed6127b07e01 Mon Sep 17 00:00:00 2001 From: Sashank Date: Sat, 19 Jul 2025 19:21:48 +0530 Subject: [PATCH 1/5] Address review feedback for vCalAddress.new() method - Add case-insensitive mailto: prefix handling with static helper method - Implement comprehensive parametrized test structure as suggested - Fix error type from AttributeError to TypeError - Add static _get_email() method to avoid code duplication - Support all RFC 5545 parameters with automatic mailto: handling - Integration confirmed with Event.new() attendees parameter - Comprehensive test coverage with 36 test cases --- src/icalendar/prop.py | 103 ++++++++ .../tests/test_issue_870_vcaladdress_new.py | 232 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/icalendar/tests/test_issue_870_vcaladdress_new.py diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 92f840bba..e80ce89d7 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -292,6 +292,109 @@ def email(self) -> str: name = CN + @staticmethod + def _get_email(email: str) -> str: + """Extract email and add mailto: prefix if needed. + + Handles case-insensitive mailto: prefix checking. + + Args: + email: Email string that may or may not have mailto: prefix + + Returns: + Email string with mailto: prefix + """ + if not email.lower().startswith("mailto:"): + return f"mailto:{email}" + return email + + @classmethod + def new( + cls, + email: str, + /, + cn: str | None = None, + cutype: str | None = None, + delegated_from: str | None = None, + delegated_to: str | None = None, + directory: str | None = None, + language: str | None = None, + partstat: str | None = None, + role: str | None = None, + rsvp: bool | None = None, + sent_by: str | None = None, + ): + """Create a new vCalAddress with RFC 5545 parameters. + + Creates a vCalAddress instance with automatic mailto: prefix handling + and support for all standard RFC 5545 parameters. + + Args: + email: The email address (mailto: prefix added automatically if missing) + cn: Common Name parameter + cutype: Calendar user type (INDIVIDUAL, GROUP, RESOURCE, ROOM) + delegated_from: Email of the calendar user that delegated + delegated_to: Email of the calendar user that was delegated to + directory: Reference to directory information + language: Language for text values + partstat: Participation status (NEEDS-ACTION, ACCEPTED, DECLINED, etc.) + role: Role (REQ-PARTICIPANT, OPT-PARTICIPANT, NON-PARTICIPANT, CHAIR) + rsvp: Whether RSVP is requested + sent_by: Email of the calendar user acting on behalf of this user + + Returns: + vCalAddress: A new calendar address with specified parameters + + Raises: + TypeError: If email is not a string + + Examples: + Basic usage: + + >>> addr = vCalAddress.new("test@test.com") + >>> str(addr) + 'mailto:test@test.com' + + With parameters: + + >>> addr = vCalAddress.new("test@test.com", cn="Test User", role="CHAIR") + >>> addr.params["CN"] + 'Test User' + >>> addr.params["ROLE"] + 'CHAIR' + """ + if not isinstance(email, str): + raise TypeError(f"Email must be a string, not {type(email).__name__}") + + # Handle mailto: prefix (case-insensitive) + email_with_prefix = cls._get_email(email) + + # Create the address + addr = cls(email_with_prefix) + + # Set parameters if provided + if cn is not None: + addr.params["CN"] = cn + if cutype is not None: + addr.params["CUTYPE"] = cutype + if delegated_from is not None: + addr.params["DELEGATED-FROM"] = cls._get_email(delegated_from) + if delegated_to is not None: + addr.params["DELEGATED-TO"] = cls._get_email(delegated_to) + if directory is not None: + addr.params["DIR"] = directory + if language is not None: + addr.params["LANGUAGE"] = language + if partstat is not None: + addr.params["PARTSTAT"] = partstat + if role is not None: + addr.params["ROLE"] = role + if rsvp is not None: + addr.params["RSVP"] = "TRUE" if rsvp else "FALSE" + if sent_by is not None: + addr.params["SENT-BY"] = cls._get_email(sent_by) + + return addr class vFloat(float): """Float diff --git a/src/icalendar/tests/test_issue_870_vcaladdress_new.py b/src/icalendar/tests/test_issue_870_vcaladdress_new.py new file mode 100644 index 000000000..2c692cc9e --- /dev/null +++ b/src/icalendar/tests/test_issue_870_vcaladdress_new.py @@ -0,0 +1,232 @@ +"""Tests for issue #870: Add new() method to vCalAddress class.""" + +from datetime import datetime + +import pytest + +from icalendar import Event +from icalendar.prop import vCalAddress + + +class TestVCalAddressNew: + """Test the vCalAddress.new() method functionality.""" + + def test_new_with_email_only(self): + """Test creating vCalAddress with email only.""" + addr = vCalAddress.new("test@test.com") + + assert str(addr) == "mailto:test@test.com" + assert len(addr.params) == 0 + + def test_new_with_mailto_prefix(self): + """Test that existing mailto: prefix is preserved.""" + addr = vCalAddress.new("mailto:test@test.com") + + assert str(addr) == "mailto:test@test.com" + + def test_new_with_case_insensitive_mailto(self): + """Test case-insensitive mailto: handling.""" + test_cases = [ + "MAILTO:test@test.com", + "Mailto:test@test.com", + "MailTo:test@test.com", + "mAiLtO:test@test.com", + ] + + for email in test_cases: + addr = vCalAddress.new(email) + assert str(addr) == email # Preserve original case + + def test_new_with_cn_parameter(self): + """Test creating vCalAddress with CN parameter.""" + addr = vCalAddress.new("test@test.com", cn="Test User") + + assert str(addr) == "mailto:test@test.com" + assert addr.params["CN"] == "Test User" + + @pytest.mark.parametrize( + ("argument", "value", "parameter", "expected_value"), + [ + ("cutype", "INDIVIDUAL", "CUTYPE", "INDIVIDUAL"), + ("cutype", "GROUP", "CUTYPE", "GROUP"), + ("cutype", "RESOURCE", "CUTYPE", "RESOURCE"), + ("cutype", "ROOM", "CUTYPE", "ROOM"), + ("role", "REQ-PARTICIPANT", "ROLE", "REQ-PARTICIPANT"), + ("role", "OPT-PARTICIPANT", "ROLE", "OPT-PARTICIPANT"), + ("role", "NON-PARTICIPANT", "ROLE", "NON-PARTICIPANT"), + ("role", "CHAIR", "ROLE", "CHAIR"), + ("partstat", "NEEDS-ACTION", "PARTSTAT", "NEEDS-ACTION"), + ("partstat", "ACCEPTED", "PARTSTAT", "ACCEPTED"), + ("partstat", "DECLINED", "PARTSTAT", "DECLINED"), + ("partstat", "TENTATIVE", "PARTSTAT", "TENTATIVE"), + ("partstat", "DELEGATED", "PARTSTAT", "DELEGATED"), + ("language", "en-US", "LANGUAGE", "en-US"), + ("language", "de-DE", "LANGUAGE", "de-DE"), + ("directory", "ldap://example.com", "DIR", "ldap://example.com"), + ], + ) + def test_new_with_string_parameters( + self, argument, value, parameter, expected_value + ): + """Test vCalAddress.new() with various string parameters.""" + kwargs = {argument: value} + addr = vCalAddress.new("test@test.com", **kwargs) + + assert str(addr) == "mailto:test@test.com" + assert addr.params[parameter] == expected_value + + @pytest.mark.parametrize( + ("rsvp_value", "expected_param"), + [ + (True, "TRUE"), + (False, "FALSE"), + ], + ) + def test_new_with_rsvp_parameter(self, rsvp_value, expected_param): + """Test vCalAddress.new() with RSVP parameter.""" + addr = vCalAddress.new("test@test.com", rsvp=rsvp_value) + + assert str(addr) == "mailto:test@test.com" + assert addr.params["RSVP"] == expected_param + + @pytest.mark.parametrize( + ("delegate_param", "delegate_value", "expected_param", "expected_value"), + [ + ( + "delegated_from", + "sender@test.com", + "DELEGATED-FROM", + "mailto:sender@test.com", + ), + ( + "delegated_from", + "mailto:sender@test.com", + "DELEGATED-FROM", + "mailto:sender@test.com", + ), + ( + "delegated_to", + "delegate@test.com", + "DELEGATED-TO", + "mailto:delegate@test.com", + ), + ( + "delegated_to", + "MAILTO:delegate@test.com", + "DELEGATED-TO", + "MAILTO:delegate@test.com", + ), + ("sent_by", "secretary@test.com", "SENT-BY", "mailto:secretary@test.com"), + ( + "sent_by", + "mailto:secretary@test.com", + "SENT-BY", + "mailto:secretary@test.com", + ), + ], + ) + def test_new_with_email_parameters( + self, delegate_param, delegate_value, expected_param, expected_value + ): + """Test vCalAddress.new() with email delegation parameters.""" + kwargs = {delegate_param: delegate_value} + addr = vCalAddress.new("test@test.com", **kwargs) + + assert str(addr) == "mailto:test@test.com" + assert addr.params[expected_param] == expected_value + + def test_new_with_all_parameters(self): + """Test creating vCalAddress with all parameters.""" + addr = vCalAddress.new( + "test@test.com", + cn="Test User", + cutype="INDIVIDUAL", + delegated_from="sender@test.com", + delegated_to="delegate@test.com", + directory="ldap://example.com", + language="en-US", + partstat="ACCEPTED", + role="REQ-PARTICIPANT", + rsvp=True, + sent_by="secretary@test.com", + ) + + assert str(addr) == "mailto:test@test.com" + assert addr.params["CN"] == "Test User" + assert addr.params["CUTYPE"] == "INDIVIDUAL" + assert addr.params["DELEGATED-FROM"] == "mailto:sender@test.com" + assert addr.params["DELEGATED-TO"] == "mailto:delegate@test.com" + assert addr.params["DIR"] == "ldap://example.com" + assert addr.params["LANGUAGE"] == "en-US" + assert addr.params["PARTSTAT"] == "ACCEPTED" + assert addr.params["ROLE"] == "REQ-PARTICIPANT" + assert addr.params["RSVP"] == "TRUE" + assert addr.params["SENT-BY"] == "mailto:secretary@test.com" + + def test_new_with_none_parameters_ignored(self): + """Test that None parameters are ignored.""" + addr = vCalAddress.new( + "test@test.com", + cn=None, + role=None, + rsvp=None, + ) + + assert str(addr) == "mailto:test@test.com" + assert "CN" not in addr.params + assert "ROLE" not in addr.params + assert "RSVP" not in addr.params + + def test_new_integration_with_event(self): + """Test that vCalAddress.new() works with Event attendees.""" + addr = vCalAddress.new("test@test.com", cn="Test User", rsvp=True) + event = Event.new(attendees=[addr]) + + assert event.attendees == [addr] + attendee = event.attendees[0] + assert str(attendee) == "mailto:test@test.com" + assert attendee.params["CN"] == "Test User" + assert attendee.params["RSVP"] == "TRUE" + + +class TestVCalAddressNewErrorCases: + """Test error cases for vCalAddress.new() method.""" + + def test_new_requires_email(self): + """Test that email parameter is required.""" + with pytest.raises(TypeError): + vCalAddress.new() # Missing required email argument + + def test_new_email_must_be_string(self): + """Test that email must be a string.""" + with pytest.raises(TypeError, match="Email must be a string, not int"): + vCalAddress.new(123) + + with pytest.raises(TypeError, match="Email must be a string, not list"): + vCalAddress.new(["test@test.com"]) + + +class TestVCalAddressNewExamples: + """Test examples that demonstrate vCalAddress.new() usage.""" + + def test_basic_usage_example(self): + """Test basic usage example from docstring.""" + addr = vCalAddress.new("test@test.com") + assert str(addr) == "mailto:test@test.com" + + def test_with_parameters_example(self): + """Test example with parameters from docstring.""" + addr = vCalAddress.new("test@test.com", cn="Test User", role="CHAIR") + assert addr.params["CN"] == "Test User" + assert addr.params["ROLE"] == "CHAIR" + + def test_consistent_with_event_new(self): + """Test that vCalAddress.new() integrates well with Event.new().""" + # Both should create objects with .new() class method + event = Event.new(summary="Test", start=datetime(2026, 1, 1, 12, 0)) + addr = vCalAddress.new("test@test.com", cn="Test User") + + # Should be able to add the address to event attendees + event.attendees = [addr] + assert len(event.attendees) == 1 + assert event.attendees[0].params["CN"] == "Test User" From 0fdff1343025c81578794b0ff69b894fb6d043f3 Mon Sep 17 00:00:00 2001 From: Sashank Date: Sat, 19 Jul 2025 19:40:17 +0530 Subject: [PATCH 2/5] Fix doctest example to handle line endings properly --- src/icalendar/prop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index e80ce89d7..4fee93b71 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -237,7 +237,7 @@ class vCalAddress(str): >>> jane = vCalAddress("mailto:jane_doe@example.com") >>> jane.name = "Jane" >>> event["organizer"] = jane - >>> print(event.to_ical()) + >>> print(event.to_ical().decode().replace('\\r\\n', '\\n').strip()) BEGIN:VEVENT ORGANIZER;CN=Jane:mailto:jane_doe@example.com END:VEVENT From f289d6dac55e342d8d9f03ec9879d7cd0938f482 Mon Sep 17 00:00:00 2001 From: Sashank Date: Sat, 19 Jul 2025 19:45:13 +0530 Subject: [PATCH 3/5] Add CHANGES.rst entry for vCalAddress.new() method --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index fed197f3f..95aff2b53 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,7 @@ Minor changes: - Enhanced ``Calendar.new()`` to support organization and language parameters for automatic ``PRODID`` generation. - Added ``duration`` setter to ``Event`` class for more intuitive event creation. - Added ``validate()`` method to ``Calendar`` class for explicit validation of required properties and components. +- Add ``new()`` method to ``vCalAddress`` class for consistent API usage. The method supports all RFC 5545 parameters including ``CN``, ``CUTYPE``, ``DELEGATED-FROM``, ``DELEGATED-TO``, ``DIR``, ``LANGUAGE``, ``PARTSTAT``, ``ROLE``, ``RSVP``, and ``SENT-BY``, with automatic ``mailto:`` prefix handling. See `Issue 870 `_. Breaking changes: From c08086ab13ea716a2ef4cfaaf2702f243a05dbaa Mon Sep 17 00:00:00 2001 From: Sashank Date: Sat, 19 Jul 2025 19:45:43 +0530 Subject: [PATCH 4/5] Apply ruff formatting to PR-specific files --- src/icalendar/prop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 4fee93b71..90119d85c 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -396,6 +396,7 @@ def new( return addr + class vFloat(float): """Float From 142a79412176dde2b577e124af907c57da02f3f8 Mon Sep 17 00:00:00 2001 From: Sashank Date: Sat, 19 Jul 2025 19:50:53 +0530 Subject: [PATCH 5/5] Fix doctest import for vCalAddress.new() examples --- src/icalendar/prop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 90119d85c..87dd8e7b7 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -351,6 +351,7 @@ def new( Examples: Basic usage: + >>> from icalendar.prop import vCalAddress >>> addr = vCalAddress.new("test@test.com") >>> str(addr) 'mailto:test@test.com'