Skip to content

Commit

Permalink
Add DTSTART, DUE, DURATION, start, end, duration to VTODO
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann committed Oct 17, 2024
1 parent 61c89a1 commit 9461059
Show file tree
Hide file tree
Showing 2 changed files with 156 additions and 55 deletions.
155 changes: 124 additions & 31 deletions src/icalendar/cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,48 @@ def is_datetime(dt: date) -> bool:
"""Whether this is a date and not a datetime."""
return isinstance(dt, datetime)

def _get_duration(self: Component) -> Optional[timedelta]:
"""Getter for property DURATION."""
default = object()
duration = self.get("duration", default)
if isinstance(duration, vDDDTypes):
return duration.dt
if isinstance(duration, vDuration):
return duration.td
if duration is not default and not isinstance(duration, timedelta):
raise InvalidCalendar(
f"DURATION must be a timedelta, not {type(duration).__name__}."
)
return None

def _set_duration(self: Component, value: Optional[timedelta]):
"""Setter for property DURATION."""
if value is None:
self.pop("duration", None)
return
if not isinstance(value, timedelta):
raise TypeError(f"Use timedelta, not {type(value).__name__}.")
self["duration"] = vDuration(value)
self.pop("DTEND")
self.pop("DUE")


def _del_duration(self: Component):
"""Delete property DURATION."""
self.pop("DURATION")

_doc_duration = """The DURATION of {component}.
The "DTSTART" property for a "{component}" specifies the inclusive start of the event.
The "DURATION" property in conjunction with the DTSTART property
for a "{component}" calendar component specifies the non-inclusive end
of the event.
If you would like to calculate the duration a {component}s do not use this.
Instead use duration property (lower case).
"""


class Event(Component):

name = 'VEVENT'
Expand Down Expand Up @@ -593,41 +635,12 @@ def _get_start_end_duration(self):
raise InvalidCalendar("DTSTART and DTEND must be of the same type, either date or datetime.")
return start, end, duration

@property
def DURATION(self) -> Optional[timedelta]: # noqa: N802
"""The DURATION of the component.
The "DTSTART" property for a "VEVENT" specifies the inclusive start of the event.
The "DURATION" property in conjunction with the DTSTART property
for a "VEVENT" calendar component specifies the non-inclusive end
of the event.

If you would like to calculate the duration of an event do not use this.
Instead use the difference between DTSTART and DTEND.
"""
default = object()
duration = self.get("duration", default)
if isinstance(duration, vDDDTypes):
return duration.dt
if isinstance(duration, vDuration):
return duration.td
if duration is not default and not isinstance(duration, timedelta):
raise InvalidCalendar(f"DURATION must be a timedelta, not {type(duration).__name__}.")
return None

@DURATION.setter
def DURATION(self, value: Optional[timedelta]): # noqa: N802
if value is None:
self.pop("duration", None)
return
if not isinstance(value, timedelta):
raise TypeError(f"Use timedelta, not {type(value).__name__}.")
self["duration"] = vDuration(value)
del self.DTEND
DURATION = property(_get_duration, _set_duration, _del_duration, _doc_duration.format(component='VEVENT'))

@property
def duration(self) -> timedelta:
"""The duration of the component.
"""The duration of the VEVENT.
This duration is calculated from the start and end of the event.
You cannot set the duration as it is unclear what happens to start and end.
Expand Down Expand Up @@ -710,7 +723,87 @@ class Todo(Component):
)
DTSTART = create_single_property("DTSTART", "dt", (datetime, date), date, 'The "DTSTART" property for a "VTODO" specifies the inclusive start of the Todo.')
DUE = create_single_property("DUE", "dt", (datetime, date), date, 'The "DUE" property for a "VTODO" calendar component specifies the non-inclusive end of the Todo.')
DURATION = property(_get_duration, _set_duration, _del_duration, _doc_duration.format(component='VTODO'))

def _get_start_end_duration(self):
"""Verify the calendar validity and return the right attributes."""
start = self.DTSTART
end = self.DUE
duration = self.DURATION
if duration is not None and end is not None:
raise InvalidCalendar("Only one of DUE and DURATION may be in a VTODO, not both.")
if isinstance(start, date) and not isinstance(start, datetime) and duration is not None and duration.seconds != 0:
raise InvalidCalendar("When DTSTART is a date, DURATION must be of days or weeks.")
if start is not None and end is not None and is_date(start) != is_date(end):
raise InvalidCalendar("DTSTART and DUE must be of the same type, either date or datetime.")
return start, end, duration


@property
def start(self) -> date | datetime:
"""The start of the VTODO.
Invalid values raise an InvalidCalendar.
If there is no start, we also raise an IncompleteComponent error.
You can get the start, end and duration of a todo as follows:
>>> from datetime import datetime
>>> from icalendar import Todo
>>> event = Todo()
>>> event.start = datetime(2021, 1, 1, 12)
>>> event.end = datetime(2021, 1, 1, 12, 30) # 30 minutes
>>> event.end - event.start # 1800 seconds == 30 minutes
datetime.timedelta(seconds=1800)
>>> print(event.to_ical())
BEGIN:VTODO
DTSTART:20210101T120000
DUE:20210101T123000
END:VTODO
"""
start = self._get_start_end_duration()[0]
if start is None:
raise IncompleteComponent("No DTSTART given.")
return start

@start.setter
def start(self, start: Optional[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.
"""
start, end, duration = self._get_start_end_duration()
if end is None and duration is None:
if start is None:
raise IncompleteComponent("No DUE or DURATION+DTSTART given.")
if is_date(start):
return start + timedelta(days=1)
return start
if duration is not None:
if start is not None:
return start + duration
raise IncompleteComponent("No DUE or DURATION+DTSTART given.")
return end

@end.setter
def end(self, end: date | datetime | None):
"""Set the end."""
self.DUE = end

@property
def duration(self) -> timedelta:
"""The duration of the VTODO.
This duration is calculated from the start and end of the event.
You cannot set the duration as it is unclear what happens to start and end.
"""
return self.end - self.start


class Journal(Component):
Expand Down
56 changes: 32 additions & 24 deletions src/icalendar/tests/test_issue_662_component_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,37 @@ def start_end_component(request):
date(2022, 7, 22),
datetime(2022, 7, 22, 13, 7, tzinfo=ZoneInfo("Europe/Paris")),
])
def dtstart(request, set_event_start, start_end_component):
def dtstart(request, set_component_start, start_end_component):
"""Start of the event."""
set_event_start(start_end_component, request.param)
set_component_start(start_end_component, request.param)
return request.param


def _set_event_start_init(event, start):
def _set_component_start_init(component, start):
"""Create the event with the __init__ method."""
d = dict(event)
d = dict(component)
d["dtstart"] = vDDDTypes(start)
event.clear()
event.update(Event(d))
component.clear()
component.update(type(component)(d))

def _set_event_dtstart(event, start):
def _set_component_dtstart(component, start):
"""Create the event with the dtstart property."""
event.DTSTART = start
component.DTSTART = start

def _set_event_start_attr(event, start):
def _set_component_start_attr(component, start):
"""Create the event with the dtstart property."""
event.start = start
component.start = start

def _set_event_start_ics(event, start):
def _set_component_start_ics(component, start):
"""Create the event with the start property."""
event.add("dtstart", start)
ics = event.to_ical().decode()
component.add("dtstart", start)
ics = component.to_ical().decode()
print(ics)
event.clear()
event.update(Event.from_ical(ics))
component.clear()
component.update(type(component).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):
@pytest.fixture(params=[_set_component_start_init, _set_component_start_ics, _set_component_dtstart, _set_component_start_attr])
def set_component_start(request):
"""Create a new event."""
return request.param

Expand Down Expand Up @@ -145,15 +145,15 @@ def _set_component_end_init(component, end):

def _set_component_end_property(component, end):
"""Create the event with the dtend property."""
setattr(component, prop("DTEND"), end)
setattr(component, prop(component, "DTEND"), end)

def _set_component_end_attr(component, end):
"""Create the event with the dtend property."""
component.end = end

def _set_component_end_ics(component, end):
"""Create the event with the end property."""
component.add("dtend", end)
component.add(prop(component, "DTEND"), end)
ics = component.to_ical().decode()
print(ics)
component.clear()
Expand All @@ -166,7 +166,8 @@ def set_component_end(request):

def test_component_end_property(dtend, start_end_component):
"""Test the end of events."""
assert start_end_component.DTEND == dtend # noqa: SIM300
attr = prop(start_end_component, "DTEND")
assert getattr(start_end_component, attr) == dtend # noqa: SIM300


def test_component_end(dtend, start_end_component):
Expand Down Expand Up @@ -229,17 +230,17 @@ def test_start_and_duration(start_end_component, dtstart, duration):
invalid_event_end_4.add("DTSTART", date(2024, 1, 1))
invalid_event_end_4.add("DURATION", timedelta(hours=1))

invalid_todo_end_1 = Event()
invalid_todo_end_1 = Todo()
invalid_todo_end_1.add("DTSTART", datetime(2024, 1, 1, 10, 20))
invalid_todo_end_1.add("DUE", date(2024, 1, 1))
invalid_todo_end_2 = Event()
invalid_todo_end_2 = Todo()
invalid_todo_end_2.add("DUE", datetime(2024, 1, 1, 10, 20))
invalid_todo_end_2.add("DTSTART", date(2024, 1, 1))
invalid_todo_end_3 = Event()
invalid_todo_end_3 = Todo()
invalid_todo_end_3.add("DUE", datetime(2024, 1, 1, 10, 20))
invalid_todo_end_3.add("DTSTART", datetime(2024, 1, 1, 10, 20))
invalid_todo_end_3.add("DURATION", timedelta(days=1))
invalid_todo_end_4 = Event()
invalid_todo_end_4 = Todo()
invalid_todo_end_4.add("DTSTART", date(2024, 1, 1))
invalid_todo_end_4.add("DURATION", timedelta(hours=1))

Expand Down Expand Up @@ -451,6 +452,13 @@ def test_invalid_none(start_end_component, attr):
with pytest.raises(InvalidCalendar):
getattr(start_end_component, attr)


def test_delete_duration(start_end_component):
"""Test the del command."""
start_end_component.DURATION = timedelta(days=1)
del start_end_component.DURATION
assert start_end_component.DURATION is None

@pytest.mark.parametrize("attr", ["DTSTART", "end", "start"])
@pytest.mark.parametrize("start", [
datetime(2024, 10, 11, 10, 20),
Expand Down

0 comments on commit 9461059

Please sign in to comment.