Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/collective/icalendar/issues/870>`_.

Breaking changes:

Expand Down
107 changes: 106 additions & 1 deletion src/icalendar/prop.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ class vCalAddress(str):
>>> jane = vCalAddress("mailto:jane_doe@example.com")
>>> jane.name = "Jane"
>>> event["organizer"] = jane
>>> print(event.to_ical())
Comment thread
niccokunzmann marked this conversation as resolved.
>>> print(event.to_ical().decode().replace('\\r\\n', '\\n').strip())
BEGIN:VEVENT
ORGANIZER;CN=Jane:mailto:jane_doe@example.com
END:VEVENT
Expand Down Expand Up @@ -292,6 +292,111 @@ 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:

>>> from icalendar.prop import vCalAddress
>>> 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
Expand Down
232 changes: 232 additions & 0 deletions src/icalendar/tests/test_issue_870_vcaladdress_new.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
SashankBhamidi marked this conversation as resolved.
"""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")
Comment thread
SashankBhamidi marked this conversation as resolved.

# 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"
Loading