Skip to content

Commit

Permalink
Merge branch 'HESPUL-replace_asserts_by_exceptions'
Browse files Browse the repository at this point in the history
  • Loading branch information
niccokunzmann committed Apr 5, 2024
2 parents 2428c3f + 39e2920 commit e154d5e
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 17 deletions.
104 changes: 88 additions & 16 deletions recurring_ical_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Calculate repetitions of icalendar components.
"""
import icalendar
import datetime
import pytz
Expand All @@ -24,6 +26,54 @@
import functools


class InvalidCalendar(ValueError):
"""Exception thrown for bad icalendar content."""

def __init__(self, message:str):
"""Create a new error with a message."""
self._message = message
super().__init__(self.message)

@property
def message(self) -> str:
"""The error message."""
return self._message


class PeriodEndBeforeStart(InvalidCalendar):
"""An event or component starts before it ends."""

def __init__(self, message, start, end):
"""Create a new PeriodEndBeforeStart error."""
super().__init__(message)
self._start = start
self._end = end

@property
def start(self):
"""The start of the component's period."""
return self._start

@property
def end(self):
"""The end of the component's period."""
return self._end


class BadRuleStringFormat(InvalidCalendar):
"""An iCal rule string is badly formatted."""

def __init__(self, message:str, rule:str):
"""Create an error with a bad rule string."""
super().__init__(message + ": " + rule)
self._rule = rule

@property
def rule(self) -> str:
"""The malformed rule string"""
return self._rule


if sys.version_info[0] == 2:
# Python2 has no ZoneInfo. We can assume that pytz is used.
_EPOCH = datetime.datetime.utcfromtimestamp(0)
Expand Down Expand Up @@ -77,13 +127,17 @@ def time_span_contains_event(span_start, span_stop, event_start, event_stop, com
This is an essential function of the module. It should be tested in
test/test_time_span_contains_event.py.
This raises a PeriodEndBeforeStart exception if a start is after an end.
"""
if not comparable:
span_start, span_stop, event_start, event_stop = make_comparable((
span_start, span_stop, event_start, event_stop
))
assert event_start <= event_stop, "the event must start before it ends"
assert span_start <= span_stop, "the time span must start before it ends"
if event_start > event_stop:
raise PeriodEndBeforeStart(f"the event must start before it ends (start: {event_start} end: {event_stop})", event_start, event_stop)
if span_start > span_stop:
raise PeriodEndBeforeStart(f"the time span must start before it ends (start: {span_start} end: {span_stop})", span_start, span_stop)
if event_start == event_stop:
if span_start == span_stop:
return event_start == span_start
Expand Down Expand Up @@ -177,6 +231,7 @@ def __repr__(self):
"""Debug representation with more info."""
return "{}({{'UID':{}...}}, {}, {})".format(self.__class__.__name__, self.source.get("UID"), self.start, self.stop)


class RepeatedComponent:
"""Base class for RepeatedEvent, RepeatedJournal and RepeatedTodo"""

Expand Down Expand Up @@ -225,7 +280,7 @@ def __init__(self, component, keep_recurrence_attributes=False):
if _rule not in _dedup_rules:
_dedup_rules.append(_rule)
_component_rules = _dedup_rules

for _rule in _component_rules:
rrule = self.create_rule_with_start(_rule.to_ical().decode())
self.rule.rrule(rrule)
Expand All @@ -236,7 +291,7 @@ def __init__(self, component, keep_recurrence_attributes=False):
self.rule.exdate(exdate)
for rdate in self.rdates:
self.rule.rdate(rdate)

if not self.until or not compare_greater(self.start, self.until):
self.rule.rdate(self.start)

Expand All @@ -252,7 +307,8 @@ def create_rule_with_start(self, rule_string):
# start: 2019-08-01 14:00:00+01:00
# ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
rule_list = rule_string.split(";UNTIL=")
assert len(rule_list) == 2
if len(rule_list) != 2:
raise BadRuleStringFormat("UNTIL parameter is missing", rule_string) from None
date_end_index = rule_list[1].find(";")
if date_end_index == -1:
date_end_index = len(rule_list[1])
Expand All @@ -266,7 +322,8 @@ def create_rule_with_start(self, rule_string):
# we assume the start is timezone aware but the until value is not, see the comment above
if len(until_string) == 8:
until_string += "T000000"
assert len(until_string) == 15
if len(until_string) != 15:
raise BadRuleStringFormat("UNTIL parameter has a bad format", rule_string) from None
until_string += "Z" # https://stackoverflow.com/a/49991809
new_rule_string = rule_list[0] + rule_list[1][date_end_index:] + ";UNTIL=" + until_string
return self.rrulestr(new_rule_string)
Expand All @@ -290,7 +347,8 @@ def _get_rrule_until(self, rrule):
rule_list = rrule.string.split(";UNTIL=")
if len(rule_list) == 1:
return None
assert len(rule_list) == 2, "There should be only one UNTIL."
if len(rule_list) != 2:
raise BadRuleStringFormat("There should be only one UNTIL", rrule)
date_end_index = rule_list[1].find(";")
if date_end_index == -1:
date_end_index = len(rule_list[1])
Expand Down Expand Up @@ -474,12 +532,14 @@ def _get_component_start(self):
DATE_MAX = (2038, 1, 1)
DATE_MAX_DT = datetime.date(*DATE_MAX)

class UnsupportedComponent(ValueError):
"""This error is raised when a component is not supported, yet."""


class UnfoldableCalendar:
'''A calendar that can unfold its events at a certain time.'''
'''A calendar that can unfold its events at a certain time.
Functions like at(), between() and after() can be used to query the
selected components. If any malformed icalendar information is found,
an InvalidCalendar exception is raised. For other bad arguments, you
should expect a ValueError.
'''

recurrence_calculators = {
"VEVENT": RepeatedEvent,
Expand All @@ -489,11 +549,16 @@ class UnfoldableCalendar:

def __init__(self, calendar, keep_recurrence_attributes=False, components=["VEVENT"]):
"""Create an unfoldable calendar from a given calendar."""
assert calendar.get("CALSCALE", "GREGORIAN") == "GREGORIAN", "Only Gregorian calendars are supported." # https://www.kanzaki.com/docs/ical/calscale.html
if calendar.get("CALSCALE", "GREGORIAN") != "GREGORIAN":
# https://www.kanzaki.com/docs/ical/calscale.html
raise InvalidCalendar("Only Gregorian calendars are supported.")

self.repetitions = []
for component_name in components:
if component_name not in self.recurrence_calculators:
raise UnsupportedComponent(f"\"{component_name}\" is an unknown name for a component. I only know these: {', '.join(self.recurrence_calculators)}.")
raise ValueError(
f'"{component_name}" is an unknown name for a recurring component. I only know these: {", ".join(self.recurrence_calculators)}.',
)
for event in calendar.walk(component_name):
recurrence_calculator = self.recurrence_calculators[component_name]
self.repetitions.append(recurrence_calculator(event, keep_recurrence_attributes))
Expand Down Expand Up @@ -541,7 +606,8 @@ def at(self, date):
if isinstance(date, int):
date = (date,)
if isinstance(date, str):
assert len(date) == 8 and date.isdigit(), "format yyyymmdd expected"
if len(date) != 8 or not date.isdigit():
raise ValueError(f'Format yyyymmdd expected for {repr(date)}.')
date = (int(date[:4], 10), int(date[4:6], 10), int(date[6:]))
if isinstance(date, datetime.datetime):
return self.between(date, date)
Expand Down Expand Up @@ -661,7 +727,7 @@ def cmp_event(event1, event2):
earliest_end = next_end


def of(a_calendar, keep_recurrence_attributes=False, components=["VEVENT"]):
def of(a_calendar, keep_recurrence_attributes=False, components=["VEVENT"]) -> UnfoldableCalendar:
"""Unfold recurring events of a_calendar
- a_calendar is an icalendar VCALENDAR component or something like that.
Expand All @@ -670,3 +736,9 @@ def of(a_calendar, keep_recurrence_attributes=False, components=["VEVENT"]):
"""
a_calendar = x_wr_timezone.to_standard(a_calendar)
return UnfoldableCalendar(a_calendar, keep_recurrence_attributes, components)


__all__ = [
"of", "InvalidCalendar", "PeriodEndBeforeStart",
"BadRuleStringFormat", "is_pytz", "DATE_MIN", "DATE_MAX",
"UnfoldableCalendar"]
17 changes: 17 additions & 0 deletions test/calendars/bad-rrule-missing-until-event.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:blabla
DTSTART;TZID=Europe/London:20190801T140000
DTEND;TZID=Europe/London:20190801T150000
DTSTAMP:20190801T083416Z
UID:blabla
SEQUENCE:0
RRULE:FREQ=WEEKLY;UNTL=20191023;BYDAY=TH;WKST=SU
CREATED:20190729T105342Z
DESCRIPTION:blabla
LAST-MODIFIED:20190801T064315Z
LOCATION:
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
34 changes: 34 additions & 0 deletions test/calendars/end-before-start-event.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:test
X-APPLE-CALENDAR-COLOR:#e78074
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20190303T111937
DTSTAMP:20190303T111937
LAST-MODIFIED:20190303T111937
UID:UYDQSG9TH4DE0WM3QFL2J
SUMMARY:test1
DTSTART;TZID=Europe/Berlin:20190304T083000
DTEND;TZID=Europe/Berlin:20190304T080000
END:VEVENT
END:VCALENDAR
8 changes: 8 additions & 0 deletions test/test_bad_rrule_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from recurring_ical_events import BadRuleStringFormat
import pytest

def test_bad_rrule_until_format(calendars):
with pytest.raises(BadRuleStringFormat, match=r"UNTIL parameter is missing"):
calendars.bad_rrule_missing_until_event.at(2019)


8 changes: 8 additions & 0 deletions test/test_end_before_start_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytest
from recurring_ical_events import PeriodEndBeforeStart


def test_end_before_start_event(calendars):
with pytest.raises(PeriodEndBeforeStart):
calendars.end_before_start_event.at(2019)

24 changes: 23 additions & 1 deletion test/test_time_span_contains_event.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from recurring_ical_events import time_span_contains_event
from recurring_ical_events import PeriodEndBeforeStart, time_span_contains_event
import pytest
from datetime import datetime, date
from pytz import timezone, utc
Expand Down Expand Up @@ -64,3 +64,25 @@ def test_time_span_inclusion(
assert time_span_contains_event(
span_start, span_stop, event_start, event_stop,
) == result, message

@pytest.mark.parametrize(
"span_start,span_stop,event_start,event_stop,exception_message", [
(1, 2, 1, 2, None),
(1, 1, 1, 1, None),
(1, 2, 2, 1, r"^the event"),
(2, 1, 1, 2, r"^the time span"),
(date(2024,4,4), date(2024,4,4), date(2024,4,4), date(2024,4,4), None),
(date(2024,4,4), date(2024,4,5), date(2024,4,4), date(2024,4,5), None),
(date(2024,4,4), date(2024,4,5), date(2024,4,5), date(2024,4,4), r"^the event"),
(date(2024,4,5), date(2024,4,4), date(2024,4,4), date(2024,4,5), r"^the time span"),
(datetime(2024,4,4), datetime(2024,4,4), datetime(2024,4,4), datetime(2024,4,4), None),
(datetime(2024,4,4), datetime(2024,4,5), datetime(2024,4,4), datetime(2024,4,5), None),
(datetime(2024,4,4), datetime(2024,4,5), datetime(2024,4,5), datetime(2024,4,4), r"^the event"),
(datetime(2024,4,5), datetime(2024,4,4), datetime(2024,4,4), datetime(2024,4,5), r"^the time span"),
])
def test_time_span_end_before_start_raise_exception(span_start, span_stop, event_start, event_stop, exception_message):
if exception_message:
with pytest.raises(PeriodEndBeforeStart, match=exception_message):
time_span_contains_event(span_start, span_stop, event_start, event_stop)
else:
time_span_contains_event(span_start, span_stop, event_start, event_stop)

0 comments on commit e154d5e

Please sign in to comment.