From fc8872ddd8034f0653d08227b309b981472ad3b8 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Sat, 20 Sep 2025 22:25:02 +0100 Subject: [PATCH] Add CONFERENCE property --- docs/api.rst | 3 + src/icalendar/__init__.py | 2 + src/icalendar/attr.py | 118 +++++++++++++- src/icalendar/cal/component.py | 18 ++- src/icalendar/cal/event.py | 6 + src/icalendar/cal/todo.py | 6 + src/icalendar/prop/__init__.py | 5 +- src/icalendar/prop/conference.py | 96 ++++++++++++ .../tests/calendars/rfc_7986_conferences.ics | 14 ++ src/icalendar/tests/test_conference.py | 144 ++++++++++++++++++ 10 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/icalendar/prop/conference.py create mode 100644 src/icalendar/tests/calendars/rfc_7986_conferences.ics create mode 100644 src/icalendar/tests/test_conference.py diff --git a/docs/api.rst b/docs/api.rst index a177aab..0e2945a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -67,6 +67,9 @@ icalendar.prop .. automodule:: icalendar.prop :members: +.. automodule:: icalendar.prop.conference + :members: + .. automodule:: icalendar.prop.image :members: diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 562e75d..1c56951 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -74,6 +74,7 @@ from icalendar.prop import ( vUTCOffset, vWeekday, ) +from icalendar.prop.conference import Conference from icalendar.prop.image import Image # Switching the timezone provider @@ -104,6 +105,7 @@ __all__ = [ "ComponentEndMissing", "ComponentFactory", "ComponentStartMissing", + "Conference", "Event", "FeatureWillBeRemovedInFutureVersion", "FreeBusy", diff --git a/src/icalendar/attr.py b/src/icalendar/attr.py index 6a00a67..dceaa03 100644 --- a/src/icalendar/attr.py +++ b/src/icalendar/attr.py @@ -9,7 +9,15 @@ from typing import TYPE_CHECKING, Optional, Sequence, Union from icalendar.enums import BUSYTYPE, CLASS, STATUS, TRANSP, StrEnum from icalendar.error import IncompleteComponent, InvalidCalendar from icalendar.parser_tools import SEQUENCE_TYPES -from icalendar.prop import vCalAddress, vCategory, vDDDTypes, vDuration, vRecur, vText +from icalendar.prop import ( + vCalAddress, + vCategory, + vDDDTypes, + vDuration, + vRecur, + vText, +) +from icalendar.prop.conference import Conference from icalendar.prop.image import Image from icalendar.timezone import tzp from icalendar.tools import is_date @@ -1617,6 +1625,113 @@ def _get_images(self: Component) -> list[Image]: images_property = property(_get_images) + +def _get_conferences(self: Component) -> list[Conference]: + """Return the CONFERENCE properties as a list. + + Purpose: + This property specifies information for accessing a conferencing system. + + Conformance: + This property can be specified multiple times in a + "VEVENT" or "VTODO" calendar component. + + Description: + This property specifies information for accessing a + conferencing system for attendees of a meeting or task. This + might be for a telephone-based conference number dial-in with + access codes included (such as a tel: URI :rfc:`3966` or a sip: or + sips: URI :rfc:`3261`), for a web-based video chat (such as an http: + or https: URI :rfc:`7230`), or for an instant messaging group chat + room (such as an xmpp: URI :rfc:`5122`). If a specific URI for a + conferencing system is not available, a data: URI :rfc:`2397` + containing a text description can be used. + + A conference system can be a bidirectional communication channel + or a uni-directional "broadcast feed". + + The "FEATURE" property parameter is used to describe the key + capabilities of the conference system to allow a client to choose + the ones that give the required level of interaction from a set of + multiple properties. + + The "LABEL" property parameter is used to convey additional + details on the use of the URI. For example, the URIs or access + codes for the moderator and attendee of a teleconference system + could be different, and the "LABEL" property parameter could be + used to "tag" each "CONFERENCE" property to indicate which is + which. + + The "LANGUAGE" property parameter can be used to specify the + language used for text values used with this property (as per + Section 3.2.10 of :rfc:`5545`). + + Example: + The following are examples of this property: + + .. code-block:: ical + + CONFERENCE;VALUE=URI;FEATURE=PHONE,MODERATOR; + LABEL=Moderator dial-in:tel:+1-412-555-0123,,,654321 + CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-412-555-0123,,,555123 + CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-888-555-0456,,,555123 + CONFERENCE;VALUE=URI;FEATURE=CHAT; + LABEL=Chat room:xmpp:chat-123@conference.example.com + CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO; + LABEL=Attendee dial-in:https://chat.example.com/audio?id=123456 + + Get all conferences: + + .. code-block:: pycon + + >>> from icalendar import Event + >>> event = Event() + >>> event.conferences + [] + + Set a conference: + + .. code-block:: pycon + + >>> from icalendar import Event, Conference + >>> event = Event() + >>> event.conferences = [ + ... Conference( + ... "tel:+1-412-555-0123,,,654321", + ... feature="PHONE,MODERATOR", + ... label="Moderator dial-in", + ... language="EN", + ... ) + ... ] + >>> print(event.to_ical()) + BEGIN:VEVENT + CONFERENCE;FEATURE="PHONE,MODERATOR";LABEL=Moderator dial-in;LANGUAGE=EN:t + el:+1-412-555-0123,,,654321 + END:VEVENT + + """ + conferences = self.get("CONFERENCE", []) + if not isinstance(conferences, Sequence): + conferences = [conferences] + return [Conference.from_uri(conference) for conference in conferences] + + +def _set_conferences(self: Component, conferences: list[Conference] | None): + """Set the conferences.""" + _del_conferences(self) + for conference in conferences or []: + self.add("CONFERENCE", conference.to_uri()) + + +def _del_conferences(self: Component): + """Delete all conferences.""" + self.pop("CONFERENCE") + + +conferences_property = property(_get_conferences, _set_conferences, _del_conferences) + __all__ = [ "attendees_property", "busy_type_property", @@ -1624,6 +1739,7 @@ __all__ = [ "class_property", "color_property", "comments_property", + "conferences_property", "contacts_property", "create_single_property", "description_property", diff --git a/src/icalendar/cal/component.py b/src/icalendar/cal/component.py index 5cef8cb..67163ca 100644 --- a/src/icalendar/cal/component.py +++ b/src/icalendar/cal/component.py @@ -3,18 +3,20 @@ from __future__ import annotations from datetime import date, datetime, timezone -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional from icalendar.attr import comments_property, single_utc_property, uid_property from icalendar.cal.component_factory import ComponentFactory from icalendar.caselessdict import CaselessDict -from icalendar.compatibility import Self from icalendar.error import InvalidCalendar from icalendar.parser import Contentline, Contentlines, Parameters, q_join, q_split from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.prop import TypesFactory, vDDDLists, vText from icalendar.timezone import tzp +if TYPE_CHECKING: + from icalendar.compatibility import Self + _marker = [] @@ -127,7 +129,13 @@ class Component(CaselessDict): obj.params[key] = item return obj - def add(self, name, value, parameters=None, encode=1): + def add( + self, + name: str, + value, + parameters: dict[str, str] | Parameters = None, + encode: bool = True, # noqa: FBT001 + ): """Add a property. :param name: Name of the property. @@ -293,7 +301,7 @@ class Component(CaselessDict): return properties @classmethod - def from_ical(cls, st, multiple:bool=False) -> Self|list[Self]: + def from_ical(cls, st, multiple: bool = False) -> Self | list[Self]: # noqa: FBT001 """Populates the component recursively from a string.""" stack = [] # a stack of components comps = [] @@ -560,7 +568,7 @@ class Component(CaselessDict): The property can be specified once in "VEVENT", "VTODO", or "VJOURNAL" calendar components. The value MUST be specified as a date with UTC time. - + """, ) diff --git a/src/icalendar/cal/event.py b/src/icalendar/cal/event.py index 664316c..b614299 100644 --- a/src/icalendar/cal/event.py +++ b/src/icalendar/cal/event.py @@ -13,6 +13,7 @@ from icalendar.attr import ( categories_property, class_property, color_property, + conferences_property, contacts_property, create_single_property, description_property, @@ -43,6 +44,7 @@ if TYPE_CHECKING: from icalendar.alarms import Alarms from icalendar.enums import CLASS, STATUS, TRANSP from icalendar.prop import vCalAddress + from icalendar.prop.conference import Conference class Event(Component): @@ -485,6 +487,7 @@ class Event(Component): status = status_property attendees = attendees_property images = images_property + conferences = conferences_property @classmethod def new( @@ -495,6 +498,7 @@ class Event(Component): classification: CLASS | None = None, color: str | None = None, comments: list[str] | str | None = None, + conferences: list[Conference] | None = None, contacts: list[str] | str | None = None, created: date | None = None, description: str | None = None, @@ -522,6 +526,7 @@ class Event(Component): classification: The :attr:`classification` of the event. color: The :attr:`color` of the event. comments: The :attr:`Component.comments` of the event. + conferences: The :attr:`conferences` of the event. created: The :attr:`Component.created` of the event. description: The :attr:`description` of the event. end: The :attr:`end` of the event. @@ -571,6 +576,7 @@ class Event(Component): event.contacts = contacts event.status = status event.attendees = attendees + event.conferences = conferences if cls._validate_new: cls._validate_start_and_end(start, end) return event diff --git a/src/icalendar/cal/todo.py b/src/icalendar/cal/todo.py index 934636d..deb8411 100644 --- a/src/icalendar/cal/todo.py +++ b/src/icalendar/cal/todo.py @@ -13,6 +13,7 @@ from icalendar.attr import ( categories_property, class_property, color_property, + conferences_property, contacts_property, create_single_property, description_property, @@ -41,6 +42,7 @@ if TYPE_CHECKING: from icalendar.alarms import Alarms from icalendar.enums import CLASS, STATUS from icalendar.prop import vCalAddress + from icalendar.prop.conference import Conference class Todo(Component): @@ -353,6 +355,7 @@ class Todo(Component): status = status_property attendees = attendees_property images = images_property + conferences = conferences_property @classmethod def new( @@ -364,6 +367,7 @@ class Todo(Component): color: str | None = None, comments: list[str] | str | None = None, contacts: list[str] | str | None = None, + conferences: list[Conference] | None = None, created: date | None = None, description: str | None = None, end: date | datetime | None = None, @@ -389,6 +393,7 @@ class Todo(Component): classification: The :attr:`classification` of the todo. color: The :attr:`color` of the todo. comments: The :attr:`Component.comments` of the todo. + conferences: The :attr:`conferences` if the todo. created: The :attr:`Component.created` of the todo. description: The :attr:`description` of the todo. end: The :attr:`end` of the todo. @@ -435,6 +440,7 @@ class Todo(Component): todo.contacts = contacts todo.status = status todo.attendees = attendees + todo.conferences = conferences if cls._validate_new: cls._validate_start_and_end(start, end) return todo diff --git a/src/icalendar/prop/__init__.py b/src/icalendar/prop/__init__.py index a5eab86..5cd4f56 100644 --- a/src/icalendar/prop/__init__.py +++ b/src/icalendar/prop/__init__.py @@ -1769,8 +1769,8 @@ class vUri(str): def __new__( cls, - value, - encoding=DEFAULT_ENCODING, + value: str, + encoding: str = DEFAULT_ENCODING, /, params: Optional[dict[str, Any]] = None, ): @@ -2145,6 +2145,7 @@ class TypesFactory(CaselessDict): "recurrence-id": "date-time", "related-to": "text", "url": "uri", + "conference": "uri", # RFC 7986 "source": "uri", "uid": "text", # Recurrence Component Properties diff --git a/src/icalendar/prop/conference.py b/src/icalendar/prop/conference.py new file mode 100644 index 0000000..4345fee --- /dev/null +++ b/src/icalendar/prop/conference.py @@ -0,0 +1,96 @@ +"""Conferences according to Section 5.11 of :rfc:`7986`.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from icalendar.prop import vUri + + +@dataclass +class Conference: + """Conferences according to Section 5.11 of :rfc:`7986`. + + Purpose: + Information for accessing a conferencing system. + + Conformance: + This property can be specified multiple times in a + "VEVENT" or "VTODO" calendar component. + + Description: + This property specifies information for accessing a + conferencing system for attendees of a meeting or task. This + might be for a telephone-based conference number dial-in with + access codes included (such as a tel: URI :rfc:`3966` or a sip: or + sips: URI :rfc:`3261`), for a web-based video chat (such as an http: + or https: URI :rfc:`7230`), or for an instant messaging group chat + room (such as an xmpp: URI :rfc:`5122`). If a specific URI for a + conferencing system is not available, a data: URI :rfc:`2397` + containing a text description can be used. + + A conference system can be a bidirectional communication channel + or a uni-directional "broadcast feed". + + The "FEATURE" property parameter is used to describe the key + capabilities of the conference system to allow a client to choose + the ones that give the required level of interaction from a set of + multiple properties. + + The "LABEL" property parameter is used to convey additional + details on the use of the URI. For example, the URIs or access + codes for the moderator and attendee of a teleconference system + could be different, and the "LABEL" property parameter could be + used to "tag" each "CONFERENCE" property to indicate which is + which. + + The "LANGUAGE" property parameter can be used to specify the + language used for text values used with this property (as per + Section 3.2.10 of :rfc:`5545`). + + Example: + The following are examples of this property: + + .. code-block:: ical + + CONFERENCE;VALUE=URI;FEATURE=PHONE,MODERATOR; + LABEL=Moderator dial-in:tel:+1-412-555-0123,,,654321 + CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-412-555-0123,,,555123 + CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-888-555-0456,,,555123 + CONFERENCE;VALUE=URI;FEATURE=CHAT; + LABEL=Chat room:xmpp:chat-123@conference.example.com + CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO; + LABEL=Attendee dial-in:https://chat.example.com/audio?id=123456 + """ + + # see https://stackoverflow.com/a/18348004/1320237 + uri: str + feature: list[str] | str | None = None + label: list[str] | str | None = None + language: list[str] | str | None = None + + @classmethod + def from_uri(cls, uri: vUri): + """Create a Conference from a URI.""" + return cls( + uri, + feature=uri.params.get("feature"), + label=uri.params.get("label"), + language=uri.params.get("language"), + ) + + def to_uri(self) -> vUri: + """Convert the Conference to a vUri.""" + params = {} + if self.feature: + params["FEATURE"] = self.feature + if self.label: + params["LABEL"] = self.label + if self.language: + params["LANGUAGE"] = self.language + return vUri(self.uri, params=params) + + +__all__ = ["Conference"] diff --git a/src/icalendar/tests/calendars/rfc_7986_conferences.ics b/src/icalendar/tests/calendars/rfc_7986_conferences.ics new file mode 100644 index 0000000..23fb2c4 --- /dev/null +++ b/src/icalendar/tests/calendars/rfc_7986_conferences.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +CONFERENCE;VALUE=URI;FEATURE=PHONE,MODERATOR; + LABEL=Moderator dial-in:tel:+1-412-555-0123,,,654321 +CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-412-555-0123,,,555123 +CONFERENCE;VALUE=URI;FEATURE=PHONE; + LABEL=Attendee dial-in:tel:+1-888-555-0456,,,555123 +CONFERENCE;VALUE=URI;FEATURE=CHAT; + LABEL=Chat room:xmpp:chat-123@conference.example.com +CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO; + LABEL=Attendee dial-in:https://chat.example.com/audio?id=123456 +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/src/icalendar/tests/test_conference.py b/src/icalendar/tests/test_conference.py new file mode 100644 index 0000000..86120dc --- /dev/null +++ b/src/icalendar/tests/test_conference.py @@ -0,0 +1,144 @@ +"""Test conference properties.""" + +import pytest + +from icalendar import Calendar, Event, Todo, vUri +from icalendar.prop.conference import Conference + + +def test_from_empty_uri(): + """Test creation from a URI.""" + conference = Conference.from_uri(vUri("https://chat.example.com/audio?id=123456")) + assert conference.uri == "https://chat.example.com/audio?id=123456" + assert conference.feature is None + assert conference.label is None + assert conference.language is None + + +def test_from_example_uri(): + """Create a conference with lots of values.""" + conference = Conference.from_uri( + vUri( + "https://chat.example.com/audio?id=123456", + params={ + "FEATURE": "PHONE,MODERATOR", + "LABEL": "Moderator dial-in", + "LANGUAGE": "EN", + }, + ) + ) + assert conference.uri == "https://chat.example.com/audio?id=123456" + assert conference.feature == "PHONE,MODERATOR" + assert conference.label == "Moderator dial-in" + assert conference.language == "EN" + + +def test_to_uri(): + """Test creating a vURI.""" + uri = vUri( + "https://chat.example.com/audio?id=123456", + params={"FEATURE": "PHONE", "LABEL": "Moderator", "LANGUAGE": "DE"}, + ) + conference = Conference.from_uri(uri) + new_uri = conference.to_uri() + assert new_uri == uri + assert new_uri.params == uri.params + + +@pytest.fixture +def conference_1(): + """Fixture for a conference with a URI.""" + return Conference.from_uri(vUri("https://chat.example.com/audio?id=123456")) + + +@pytest.fixture +def conference_2(): + """Fixture for a conference with a URI.""" + return Conference( + vUri("https://chat.example.com/audio?id=123456"), + feature="PHONE", + label="Moderator dial-in", + language="EN", + ) + + +@pytest.fixture(params=[Event, Todo]) +def component_class(request): + """Fixture to create a Conference component.""" + return request.param + + +@pytest.fixture +def component(component_class): + """Create a component.""" + return component_class() + + +def test_no_conferenes(component): + """No conferences by default.""" + assert component.conferences == [] + assert "CONFERENCE" not in component + + +def test_add_conference(component, conference_1): + """Add a new conference.""" + component.conferences = [conference_1] + assert component["CONFERENCE"] == conference_1.to_vUri() + + +def test_add_multiple_conferences(component, conference_1, conference_2): + """Add a new conference.""" + component.conferences = [conference_1, conference_2] + assert component["CONFERENCE"] == [conference_1.to_vUri(), conference_2.to_vUri()] + + +def test_new_component_with_conferences(component_class, conference_1, conference_2): + """Create a new component with conferences.""" + component = component_class.new(conferences=[conference_1, conference_2]) + assert component.conferences == [conference_1, conference_2] + + +def test_uri_in_ical_(conference_2, component): + """The URI start be in the ical string.""" + component.conferences = [conference_2] + ical_str = component.to_ical().decode("utf-8") + assert "CONFERENCE;" in ical_str + assert "/audio?id=123456" in ical_str + assert "PHONE" in ical_str + + +def get_event(calendar: Calendar) -> Event: + """Get the event directly""" + return calendar.events[0] + + +def get_serialized_event(calendar: Calendar) -> Event: + """Get the event directly""" + cal = Calendar.from_ical(calendar.to_ical()) + return cal.events[0] + + +@pytest.fixture(params=[get_event, get_serialized_event]) +def event_with_conferences(request, calendars): + """Return the event from the file.""" + calendar = calendars.rfc_7986_conferences + return request.param(calendar) + + +def test_conferences_from_file(event_with_conferences): + """The conferences should be in the calendar.""" + assert len(event_with_conferences.conferences) == 5 + assert event_with_conferences.conferences[0].uri == "tel:+1-412-555-0123,,,654321" + assert event_with_conferences.conferences[1].uri == "tel:+1-412-555-0123,,,555123" + assert event_with_conferences.conferences[2].uri == "tel:+1-888-555-0456,,,555123" + assert ( + event_with_conferences.conferences[3].uri + == "xmpp:chat-123@conference.example.com" + ) + assert ( + event_with_conferences.conferences[4].uri + == "https://chat.example.com/audio?id=123456" + ) + assert event_with_conferences.conferences[4].feature == ["AUDIO", "VIDEO"] + assert event_with_conferences.conferences[4].label == "Attendee dial-in" + assert event_with_conferences.conferences[4].language is None