Skip to content

Commit

Permalink
Merge pull request #721 from niccokunzmann/issue-662-event-properties
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann authored Oct 13, 2024
2 parents 888d1d3 + 88d675b commit 1907deb
Show file tree
Hide file tree
Showing 7 changed files with 648 additions and 19 deletions.
2 changes: 1 addition & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Breaking changes:

New features:

- ...
- Added ``Event.end``, ``Event.start``, ``Event.dtstart``, and ``Event.dtend`` attributes. See `Issue 662 <https://github.com/collective/icalendar/issues/662>`_.

Bug fixes:

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ ignore = [
"PERF401", # Use a list comprehension to create a transformed list
"ARG002", # Unused method argument: ...
"ARG001", # Unused function argument: ...
"UP007", # Optional -> X | None remove when migrated to py39+
]
extend-safe-fixes = [
"PT006", # Wrong type passed to first argument of @pytest.mark.parametrize; expected {expected_string}
Expand Down
6 changes: 6 additions & 0 deletions src/icalendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
ComponentFactory,
Event,
FreeBusy,
IncompleteComponent,
InvalidCalendar,
Journal,
Timezone,
TimezoneDaylight,
Expand Down Expand Up @@ -34,6 +36,7 @@
vFrequency,
vGeo,
vInt,
vMonth,
vPeriod,
vRecur,
vText,
Expand Down Expand Up @@ -88,4 +91,7 @@
"version_tuple",
"TypesFactory",
"Component",
"vMonth",
"IncompleteComponent",
"InvalidCalendar",
]
250 changes: 234 additions & 16 deletions src/icalendar/cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, Optional, 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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -485,6 +497,57 @@ def __eq__(self, other):
#######################################
# components defined in RFC 5545

def create_single_property(prop:str, value_attr:str, value_type:tuple[type], type_def:type, doc:str):
"""Create a single property getter and setter."""

def p_get(self : Component):
default = object()
result = self.get(prop, default)
if result is default:
return None
if isinstance(result, list):
raise InvalidCalendar(f"Multiple {prop} defined.")
value = getattr(result, value_attr, result)
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) -> None:
if value is None:
p_del(self)
return
if not isinstance(value, value_type):
raise TypeError(f"Use {' or '.join(t.__name__ for t in value_type)}, not {type(value).__name__}.")
self[prop] = vDDDTypes(value)
if prop in self.exclusive:
for other_prop in self.exclusive:
if other_prop != prop:
self.pop(other_prop, None)
p_set.__annotations__["value"] = p_get.__annotations__["return"] = Optional[type_def]

def p_del(self:Component):
self.pop(prop)

p_doc = f"""The {prop} property.
{doc}
Accepted values: {', '.join(t.__name__ for t in value_type)}.
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 or by setting it to None.
"""
return property(p_get, p_set, p_del, p_doc)


def is_date(dt: date) -> bool:
"""Whether this is a date and not a datetime."""
return isinstance(dt, date) and not isinstance(dt, datetime)

def is_datetime(dt: date) -> bool:
"""Whether this is a date and not a datetime."""
return isinstance(dt, datetime)

class Event(Component):

name = 'VEVENT'
Expand All @@ -510,10 +573,123 @@ class Event(Component):
ignore_exceptions = True

@classmethod
def example(cls, name) -> Event:
def example(cls, name:str) -> Event:
"""Return the calendar example with the given name."""
return cls.from_ical(get_example("events", name))

DTSTART = create_single_property("DTSTART", "dt", (datetime, date), date, 'The "DTSTART" property for a "VEVENT" specifies the inclusive start of the event.')
DTEND = create_single_property("DTEND", "dt", (datetime, date), date, 'The "DTEND" property for a "VEVENT" calendar component specifies the non-inclusive end of the event.')

def _get_start_end_duration(self):
"""Verify the calendar validity and return the right attributes."""
start = self.DTSTART
end = self.DTEND
duration = self.DURATION
if duration is not None and end is not None:
raise InvalidCalendar("Only one of DTEND and DURATION may be in a VEVENT, 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 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

@property
def duration(self) -> timedelta:
"""The duration of the component.
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

@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.
You can get the start, end and duration of an event as follows:
>>> from datetime import datetime
>>> from icalendar import Event
>>> event = Event()
>>> 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:VEVENT
DTSTART:20210101T120000
DTEND:20210101T123000
END:VEVENT
"""
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 DTEND 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 DTEND or DURATION+DTSTART given.")
return end

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


class Todo(Component):
Expand All @@ -535,6 +711,19 @@ class Todo(Component):


class Journal(Component):
"""A descriptive text at a certain time or associated with a component.
A "VJOURNAL" calendar component is a grouping of
component properties that represent one or more descriptive text
notes associated with a particular calendar date. The "DTSTART"
property is used to specify the calendar date with which the
journal entry is associated. Generally, it will have a DATE value
data type, but it can also be used to specify a DATE-TIME value
data type. Examples of a journal entry include a daily record of
a legislative body or a journal entry of individual telephone
contacts for the day or an ordered list of accomplishments for the
day.
"""

name = 'VJOURNAL'

Expand All @@ -548,6 +737,34 @@ class Journal(Component):
'RELATED', 'RDATE', 'RRULE', 'RSTATUS', 'DESCRIPTION',
)

DTSTART = create_single_property(
"DTSTART", "dt", (datetime, date), date,
'The "DTSTART" property for a "VJOURNAL" that specifies the exact date at which the journal entry was made.')

@property
def start(self) -> date:
"""The start of the Journal.
The "DTSTART"
property is used to specify the calendar date with which the
journal entry is associated.
"""
start = self.DTSTART
if start is None:
raise IncompleteComponent("No DTSTART given.")
return start

@start.setter
def start(self, value: datetime|date) -> None:
"""Set the start of the journal."""
self.DTSTART = value

end = start

@property
def duration(self) -> timedelta:
"""The journal has no duration."""
return timedelta(0)

class FreeBusy(Component):

Expand All @@ -568,7 +785,7 @@ class Timezone(Component):
singletons = ('TZID', 'LAST-MODIFIED', 'TZURL',)

@classmethod
def example(cls, name) -> Calendar:
def example(cls, name: str) -> Calendar:
"""Return the calendar example with the given name."""
return cls.from_ical(get_example("timezones", name))

Expand Down Expand Up @@ -757,7 +974,7 @@ class Calendar(Component):
singletons = ('PRODID', 'VERSION', 'CALSCALE', 'METHOD')

@classmethod
def example(cls, name) -> Calendar:
def example(cls, name: str) -> Calendar:
"""Return the calendar example with the given name."""
return cls.from_ical(get_example("calendars", name))

Expand All @@ -767,4 +984,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"]
1 change: 1 addition & 0 deletions src/icalendar/prop.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ class vMonth(int):
In :rfc:`5545`, this is just an int.
In :rfc:`7529`, this can be followed by `L` to indicate a leap month.
>>> from icalendar import vMonth
>>> vMonth(1) # first month January
vMonth('1')
>>> vMonth("5L") # leap month in Hebrew calendar
Expand Down
Loading

0 comments on commit 1907deb

Please sign in to comment.