diff --git a/CHANGES.rst b/CHANGES.rst index 9015a3bd..59703fb9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,7 +14,7 @@ Breaking changes: New features: -- ... +- Add ``Event.end``, ``Event.start``, ``Event.dtstart`` and ``Event.dtend`` attributes, see `Issue 662 `_ Bug fixes: diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index b2f24e66..8a7bfeb9 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -10,6 +10,8 @@ TimezoneDaylight, TimezoneStandard, Todo, + InvalidCalendar, + IncompleteComponent ) # Parameters and helper methods for splitting and joining string with escaped diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 83553f2a..421f69f2 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -4,21 +4,18 @@ These are the defined components. """ from __future__ import annotations -from datetime import datetime, timedelta + +import os +from datetime import date, datetime, timedelta +from typing import List, Tuple + +import dateutil.rrule +import dateutil.tz from icalendar.caselessdict import CaselessDict -from icalendar.parser import Contentline -from icalendar.parser import Contentlines -from icalendar.parser import Parameters -from icalendar.parser import q_join -from icalendar.parser import q_split +from icalendar.parser import Contentline, Contentlines, Parameters, q_join, q_split from icalendar.parser_tools import DEFAULT_ENCODING -from icalendar.prop import TypesFactory -from icalendar.prop import vText, vDDDLists +from icalendar.prop import TypesFactory, vDDDLists, vDDDTypes, vText, vDuration from icalendar.timezone import tzp -from typing import Tuple, List -import dateutil.rrule -import dateutil.tz -import os def get_example(component_directory: str, example_name: str) -> bytes: @@ -67,6 +64,21 @@ def __init__(self, *args, **kwargs): _marker = [] +class InvalidCalendar(ValueError): + """The calendar given is not valid. + + This calendar does not conform with RFC 5545 or breaks other RFCs. + """ + +class IncompleteComponent(ValueError): + """The component is missing attributes. + + The attributes are not required, otherwise this would be + an InvalidCalendar. But in order to perform calculations, + this attribute is required. + """ + + class Component(CaselessDict): """Component is the base object for calendar, Event and the other @@ -485,6 +497,37 @@ def __eq__(self, other): ####################################### # components defined in RFC 5545 +def create_single_property(prop:str, value_attr:str, value_type:tuple[type]|type, type_def:type): + """Create a single property getter and setter.""" + + def p_get(self : Component) -> type_def | None: + result = self.get(prop) + if result is None: + return None + if isinstance(result, list): + raise InvalidCalendar(f"Multiple {prop} defined.") + value = getattr(result, value_attr) + if not isinstance(value, value_type): + raise InvalidCalendar(f"{prop} must be either a date or a datetime, not {value}.") + return value + + def p_set(self:Component, value: type_def) -> None: + self[prop] = vDDDTypes(value) + + def p_del(self:Component): + self.pop(prop) + + p_doc = f"""The {prop} property. + + If the attribute has invalid values, we raise InvalidCalendar. + If the value is absent, we return None. + You can also delete the value with del. + """ + return property(p_get, p_set, p_del, p_doc) + + + + class Event(Component): name = 'VEVENT' @@ -514,8 +557,49 @@ def example(cls, name) -> Event: """Return the calendar example with the given name.""" return cls.from_ical(get_example("events", name)) + dtstart = create_single_property("DTSTART", "dt", date, date) + dtend = create_single_property("DTEND", "dt", date, date) + @property + def start(self) -> date | datetime: + """The start of the component. + Invalid values raise an InvalidCalendar. + If there is no start, we also raise an IncompleteComponent error. + """ + start = self.dtstart + if start is None: + raise IncompleteComponent("No DTSTART given.") + return self.dtstart + + @start.setter + def start(self, start: date | datetime): + """Set the start.""" + self.dtstart = start + + @property + def end(self) -> date | datetime: + """The end of the component. + + Invalid values raise an InvalidCalendar error. + If there is no end, we also raise an IncompleteComponent error. + """ + end = self.dtend + duration : vDuration = self.get("duration") + if end is None and duration is None: + raise IncompleteComponent("No DTEND or DURATION+DTSTART given.") + if isinstance(duration, vDDDTypes): + return self.start + duration.dt + if isinstance(duration, vDuration): + return self.start + duration.td + return end + + @end.setter + def end(self, end: date | datetime): + """Set the start.""" + self.dtend = end + + class Todo(Component): name = 'VTODO' @@ -767,4 +851,5 @@ def example(cls, name) -> Calendar: __all__ = ["Alarm", "Calendar", "Component", "ComponentFactory", "Event", "FreeBusy", "INLINE", "Journal", "Timezone", "TimezoneDaylight", - "TimezoneStandard", "Todo", "component_factory", "get_example"] + "TimezoneStandard", "Todo", "component_factory", "get_example", + "IncompleteComponent", "InvalidCalendar"] diff --git a/src/icalendar/tests/test_issue_662_component_properties.py b/src/icalendar/tests/test_issue_662_component_properties.py new file mode 100644 index 00000000..78d9d07c --- /dev/null +++ b/src/icalendar/tests/test_issue_662_component_properties.py @@ -0,0 +1,237 @@ +"""This tests the properties of components and their types.""" +from datetime import date, datetime, timedelta + +import pytest +from zoneinfo import ZoneInfo + +from icalendar import ( + Event, + IncompleteComponent, + InvalidCalendar, + vDDDTypes, +) +from icalendar.prop import vDuration + + +@pytest.fixture +def event(): + """The event to test.""" + return Event() + +@pytest.fixture(params=[ + datetime(2022, 7, 22, 12, 7), + date(2022, 7, 22), + datetime(2022, 7, 22, 13, 7, tzinfo=ZoneInfo("Europe/Paris")), + ]) +def dtstart(request, set_event_start, event): + """Start of the event.""" + set_event_start(event, request.param) + return request.param + + +def _set_event_start_init(event, start): + """Create the event with the __init__ method.""" + d = dict(event) + d["dtstart"] = vDDDTypes(start) + event.clear() + event.update(Event(d)) + +def _set_event_dtstart(event, start): + """Create the event with the dtstart property.""" + event.dtstart = start + +def _set_event_start_attr(event, start): + """Create the event with the dtstart property.""" + event.start = start + +def _set_event_start_ics(event, start): + """Create the event with the start property.""" + event.add("dtstart", start) + ics = event.to_ical().decode() + print(ics) + event.clear() + event.update(Event.from_ical(ics)) + +@pytest.fixture(params=[_set_event_start_init, _set_event_start_ics, _set_event_dtstart, _set_event_start_attr]) +def set_event_start(request): + """Create a new event.""" + return request.param + +def test_event_dtstart(dtstart, event): + """Test the start of events.""" + assert event.dtstart == dtstart + + +def test_event_start(dtstart, event): + """Test the start of events.""" + assert event.start == dtstart + + +invalid_start_event_1 = Event() +invalid_start_event_1.add("dtstart", datetime(2022, 7, 22, 12, 7)) +invalid_start_event_1.add("dtstart", datetime(2022, 7, 22, 12, 8)) +invalid_start_event_2 = Event.from_ical(invalid_start_event_1.to_ical()) +invalid_start_event_3 = Event() +invalid_start_event_3.add("DTSTART", (date(2018, 1, 1), date(2018, 2, 1))) + +@pytest.mark.parametrize("invalid_event", [invalid_start_event_1, invalid_start_event_2, invalid_start_event_3]) +def test_multiple_dtstart(invalid_event): + """Check that we get the right error.""" + with pytest.raises(InvalidCalendar): + invalid_event.start # noqa: B018 + with pytest.raises(InvalidCalendar): + invalid_event.dtstart # noqa: B018 + +def test_no_dtstart(): + """DTSTART is optional. + + The following is REQUIRED if the component + appears in an iCalendar object that doesn't + specify the "METHOD" property; otherwise, it + is OPTIONAL; in any case, it MUST NOT occur + more than once. + """ + assert Event().dtstart is None + with pytest.raises(IncompleteComponent): + Event().start # noqa: B018 + + +@pytest.fixture(params=[ + datetime(2022, 7, 22, 12, 8), + date(2022, 7, 23), + datetime(2022, 7, 22, 14, 7, tzinfo=ZoneInfo("Europe/Paris")), + ]) +def dtend(request, set_event_end, event): + """end of the event.""" + set_event_end(event, request.param) + return request.param + + +def _set_event_end_init(event, end): + """Create the event with the __init__ method.""" + d = dict(event) + d["dtend"] = vDDDTypes(end) + event.clear() + event.update(Event(d)) + +def _set_event_dtend(event, end): + """Create the event with the dtend property.""" + event.dtend = end + +def _set_event_end_attr(event, end): + """Create the event with the dtend property.""" + event.end = end + +def _set_event_end_ics(event, end): + """Create the event with the end property.""" + event.add("dtend", end) + ics = event.to_ical().decode() + print(ics) + event.clear() + event.update(Event.from_ical(ics)) + +@pytest.fixture(params=[_set_event_end_init, _set_event_end_ics, _set_event_dtend, _set_event_end_attr]) +def set_event_end(request): + """Create a new event.""" + return request.param + +def test_event_dtend(dtend, event): + """Test the end of events.""" + assert event.dtend == dtend + + +def test_event_end(dtend, event): + """Test the end of events.""" + assert event.end == dtend + + +@pytest.mark.parametrize("attr", ["dtstart", "dtend"]) +def test_delete_attr(event, dtstart, dtend, attr): + delattr(event, attr) + assert getattr(event, attr) is None + delattr(event, attr) + + +def _set_duration_vdddtypes(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event["DURATION"] = vDDDTypes(duration) + +def _set_duration_add(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event.add("DURATION", duration) + +def _set_duration_vduration(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event["DURATION"] = vDuration(duration) + +@pytest.fixture(params=[_set_duration_vdddtypes, _set_duration_add, _set_duration_vduration]) +def duration(event, dtstart, request): + """... events have a DATE value type for the "DTSTART" property ... + If such a "VEVENT" has a "DURATION" + property, it MUST be specified as a "dur-day" or "dur-week" value. + """ + duration = timedelta(hours=1) if isinstance(dtstart, datetime) else timedelta(days=2) + request.param(event, duration) + return duration + +def test_start_and_duration(event, dtstart, duration): + """Check calculation of end with duration.""" + dur = event.end - event.start + assert dur == duration + +def test_default_duration(event, dtstart): + """Check that the end can be computed if a start is given.""" + +# The "VEVENT" is also the calendar component used to specify an +# anniversary or daily reminder within a calendar. These events +# have a DATE value type for the "DTSTART" property instead of the +# default value type of DATE-TIME. If such a "VEVENT" has a "DTEND" +# property, it MUST be specified as a DATE value also. +invalid_event_end_1 = Event() +invalid_event_end_1.add("DTSTART", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_1.add("DTEND", date(2024, 1, 1)) +invalid_event_end_2 = Event() +invalid_event_end_2.add("DTEND", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_2.add("DTSTART", date(2024, 1, 1)) +invalid_event_end_3 = Event() +invalid_event_end_3.add("DTEND", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_3.add("DTSTART", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_3.add("DURATION", timedelta(days=1)) +invalid_event_end_4 = Event() +invalid_event_end_4.add("DTSTART", date(2024, 1, 1)) +invalid_event_end_4.add("DURATION", timedelta(hours=1)) +@pytest.mark.parametrize( + ("incomplete_event_end", "message"), + [ + (invalid_event_end_1, "DTSTART and DTEND must have the same type."), + (invalid_event_end_2, "DTSTART and DTEND must have the same type."), + (invalid_event_end_3, "DURATION and DTEND cannot be there at the same time."), + (invalid_event_end_4, "When DTSTART is a date, DURATION must be of days or weeks."), + ] +) +@pytest.mark.parametrize("attr", ["start", "end"]) +def test_invalid_event(incomplete_event_end, message, attr): + """Test that the end and start throuw the right error.""" + with pytest.raises(InvalidCalendar) as e: + getattr(incomplete_event_end, attr) + assert e.value.args[0] == message + +def test_duration_one_day(): + """ + + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day + """ + + +incomplete_event_1 = Event() +incomplete_event_2 = Event() +incomplete_event_2.add("DURATION", timedelta(hours=1)) + +@pytest.mark.parametrize("incomplete_event_end", [incomplete_event_1, incomplete_event_2]) +def test_incomplete_event(incomplete_event_end): + """Test that the end throuws the right error.""" + with pytest.raises(IncompleteComponent): + incomplete_event_end.end # noqa: B018 \ No newline at end of file