Skip to content

Commit

Permalink
Remap pydantic exceptions to new ical specific exceptions (#242)
Browse files Browse the repository at this point in the history
* Remap pydantic exceptions to new ical specific exceptions

* Add exception catching for pyparsing errors

* Update parsing examples

* Bump ical to 6.0.0

* Increase test coverage of parse failures

* Add coverage for calendar stream parsing

* Streamline test coverage

* Remove unused exceptions

* Fix imports
  • Loading branch information
allenporter authored Nov 6, 2023
1 parent 99683e6 commit 1e7ba71
Show file tree
Hide file tree
Showing 25 changed files with 163 additions and 129 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ prints out the events in order:
```python
from pathlib import Path
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError

filename = Path("example/calendar.ics")
with filename.open() as ics_file:
calendar = IcsCalendarStream.calendar_from_ics(ics_file.read())

print([event.summary for event in calendar.timeline])
try:
calendar = IcsCalendarStream.calendar_from_ics(ics_file.read())
except CalendarParseError as err:
print(f"Failed to parse ics file '{str(filename)}': {err}")
else:
print([event.summary for event in calendar.timeline])
```

# Writing ics files
Expand Down
1 change: 1 addition & 0 deletions ical/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"todo",
"types",
"tzif",
"exceptions",
"util",
"diagnostics",
]
11 changes: 8 additions & 3 deletions ical/calendar_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import logging
import pyparsing

try:
from pydantic.v1 import Field
Expand All @@ -39,6 +40,7 @@
from .component import ComponentModel
from .parsing.component import encode_content, parse_content
from .types.data_types import DATA_TYPE
from .exceptions import CalendarParseError

_LOGGER = logging.getLogger(__name__)

Expand All @@ -55,13 +57,16 @@ class CalendarStream(ComponentModel):
@classmethod
def from_ics(cls, content: str) -> "CalendarStream":
"""Factory method to create a new instance from an rfc5545 iCalendar content."""
components = parse_content(content)
try:
components = parse_content(content)
except pyparsing.ParseException as err:
raise CalendarParseError(f"Failed to parse calendar stream: {err}") from err
result: dict[str, list] = {"vcalendar": []}
for component in components:
result.setdefault(component.name, [])
result[component.name].append(component.as_dict())
_LOGGER.debug("Parsing object %s", result)
return cls.parse_obj(result)
return cls(**result)

def ics(self) -> str:
"""Encode the calendar stream as an rfc5545 iCalendar Stream content."""
Expand All @@ -79,7 +84,7 @@ def calendar_from_ics(cls, content: str) -> Calendar:
return stream.calendars[0]
if len(stream.calendars) == 0:
return Calendar()
raise ValueError("Calendar Stream had more than one calendar")
raise CalendarParseError("Calendar Stream had more than one calendar")

@classmethod
def calendar_to_ics(cls, calendar: Calendar) -> str:
Expand Down
11 changes: 9 additions & 2 deletions ical/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@
from typing import Any, Union, get_args, get_origin

try:
from pydantic.v1 import BaseModel, root_validator
from pydantic.v1 import BaseModel, root_validator, ValidationError
from pydantic.v1.fields import SHAPE_LIST
except ImportError:
from pydantic import BaseModel, root_validator
from pydantic import BaseModel, root_validator, ValidationError
from pydantic.fields import SHAPE_LIST

from .parsing.component import ParsedComponent
from .parsing.property import ParsedProperty
from .types.data_types import DATA_TYPE
from .exceptions import CalendarParseError

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -122,6 +123,12 @@ def validate_recurrence_dates(
class ComponentModel(BaseModel):
"""Abstract class for rfc5545 component model."""

def __init__(self, **data: Any) -> None:
try:
super().__init__(**data)
except ValidationError as err:
raise CalendarParseError(f"Failed to parse component: {err}") from err

@root_validator(pre=True, allow_reuse=True)
def parse_extra_fields(
cls, values: dict[str, list[ParsedProperty | ParsedComponent]]
Expand Down
32 changes: 32 additions & 0 deletions ical/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Exceptions for ical library."""


class CalendarError(Exception):
"""Base exception for all ical errors."""


class CalendarParseError(CalendarError):
"""Exception raised when parsing an ical string."""


class RecurrenceError(CalendarError):
"""Exception raised when evaluating a recurrence rule.
Recurrence rules have complex logic and it is common for there to be
invalid date or bugs, so this special exception exists to help
provide additional debug data to find the source of the issue. Often
`dateutil.rrule` has limitataions and ical has to work around them
by providing special wrapping libraries.
"""


class StoreError(CalendarError):
"""Exception thrown by a Store."""


class EventStoreError(StoreError):
"""Exception thrown by the EventStore."""


class TodoStoreError(StoreError):
"""Exception thrown by the TodoStore."""
14 changes: 2 additions & 12 deletions ical/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from .calendar import Calendar
from .event import Event
from .exceptions import StoreError, TodoStoreError, EventStoreError
from .todo import Todo
from .iter import RulesetIterable
from .timezone import Timezone
Expand All @@ -32,21 +33,10 @@
"EventStoreError",
"TodoStore",
"TodoStoreError",
"StoreError",
]


class StoreError(Exception):
"""Exception thrown by a Store."""


class EventStoreError(StoreError):
"""Exception thrown by the EventStore."""


class TodoStoreError(StoreError):
"""Exception thrown by the TodoStore."""


class EventStore:
"""An event store manages the lifecycle of events on a Calendar.
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = ical
version = 5.1.1
version = 6.0.0
description = Python iCalendar implementation (rfc 2445)
long_description = file: README.md
long_description_content_type = text/markdown
Expand Down
18 changes: 7 additions & 11 deletions tests/test_alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@
import datetime

import pytest
try:
from pydantic.v1 import ValidationError
except ImportError:
from pydantic import ValidationError


from ical.alarm import Alarm
from ical.exceptions import CalendarParseError


def test_todo() -> None:
Expand All @@ -38,21 +34,21 @@ def test_duration_and_repeat() -> None:
assert alarm.repeat == 2

# Duration but no repeat
with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Alarm(
action="AUDIO",
trigger=datetime.timedelta(minutes=-5),
duration=datetime.timedelta(seconds=30),
)

# Repeat but no duration
with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Alarm(action="AUDIO", trigger=datetime.timedelta(minutes=-5), repeat=2)


def test_display_required_fields() -> None:
"""Test required fields for action DISPLAY."""
with pytest.raises(ValidationError, match="Description value is required for action DISPLAY"):
with pytest.raises(CalendarParseError, match="Description value is required for action DISPLAY"):
Alarm(action="DISPLAY", trigger=datetime.timedelta(minutes=-5))

alarm = Alarm(
Expand All @@ -67,19 +63,19 @@ def test_display_required_fields() -> None:
def test_email_required_fields() -> None:
"""Test required fields for action EMAIL."""
# Missing multiple fields
with pytest.raises(ValidationError, match="Description value is required for action EMAIL"):
with pytest.raises(CalendarParseError, match="Description value is required for action EMAIL"):
Alarm(action="EMAIL", trigger=datetime.timedelta(minutes=-5))

# Missing summary
with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Alarm(
action="EMAIL",
trigger=datetime.timedelta(minutes=-5),
description="Email description",
)

# Missing description
with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Alarm(
action="EMAIL",
trigger=datetime.timedelta(minutes=-5),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,4 +457,4 @@ def test_floating_time_with_timezone_propagation() -> None:
with patch("ical.util.local_timezone", side_effect=ValueError("do not invoke")):
it = iter(cal.timeline_tz(zoneinfo.ZoneInfo("Europe/Brussels")))
for i in range(0, 30):
next(it)
next(it)
58 changes: 49 additions & 9 deletions tests/test_calendar_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

from collections.abc import Generator
import json
import textwrap

import pytest
from pytest_golden.plugin import GoldenTestFixture

from ical.exceptions import CalendarParseError
from ical.calendar_stream import CalendarStream, IcsCalendarStream


Expand All @@ -15,20 +17,22 @@ def test_empty_ics(mock_prodid: Generator[None, None, None]) -> None:
ics = IcsCalendarStream.calendar_to_ics(calendar)
assert (
ics
== """BEGIN:VCALENDAR
PRODID:-//example//1.2.3
VERSION:2.0
END:VCALENDAR"""
)
== textwrap.dedent("""\
BEGIN:VCALENDAR
PRODID:-//example//1.2.3
VERSION:2.0
END:VCALENDAR"""
))

calendar.prodid = "-//example//1.2.4"
ics = IcsCalendarStream.calendar_to_ics(calendar)
assert (
ics
== """BEGIN:VCALENDAR
PRODID:-//example//1.2.4
VERSION:2.0
END:VCALENDAR"""
== textwrap.dedent("""\
BEGIN:VCALENDAR
PRODID:-//example//1.2.4
VERSION:2.0
END:VCALENDAR""")
)


Expand Down Expand Up @@ -58,3 +62,39 @@ def test_serialize(golden: GoldenTestFixture) -> None:
"""Fixture to read golden file and compare to golden output."""
cal = IcsCalendarStream.from_ics(golden["input"])
assert cal.ics() == golden.get("encoded", golden["input"])


def test_invalid_ics() -> None:
"""Test parsing failures for ics content."""
with pytest.raises(CalendarParseError, match="Failed to parse calendar stream"):
IcsCalendarStream.calendar_from_ics("invalid")


def test_component_failure() -> None:
with pytest.raises(CalendarParseError, match="Failed to parse component"):
IcsCalendarStream.calendar_from_ics(
textwrap.dedent("""\
BEGIN:VCALENDAR
PRODID:-//example//1.2.3
VERSION:2.0
BEGIN:VEVENT
DTSTART:20220724T120000
DTEND:20220724
END:VEVENT
END:VCALENDAR
"""))


def test_multiple_calendars() -> None:
with pytest.raises(CalendarParseError, match="more than one calendar"):
IcsCalendarStream.calendar_from_ics(
textwrap.dedent("""\
BEGIN:VCALENDAR
PRODID:-//example//1.2.3
VERSION:2.0
END:VCALENDAR
BEGIN:VCALENDAR
PRODID:-//example//1.2.3
VERSION:2.0
END:VCALENDAR
"""))
13 changes: 7 additions & 6 deletions tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydantic import ValidationError

from ical.event import Event
from ical.exceptions import CalendarParseError

SUMMARY = "test summary"
LOS_ANGELES = zoneinfo.ZoneInfo("America/Los_Angeles")
Expand Down Expand Up @@ -164,12 +165,12 @@ def test_within_and_includes() -> None:
def test_start_end_same_type() -> None:
"""Verify that the start and end value are the same type."""

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(
summary=SUMMARY, start=date(2022, 9, 9), end=datetime(2022, 9, 9, 11, 0, 0)
)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(
summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0), end=date(2022, 9, 9)
)
Expand All @@ -190,14 +191,14 @@ def test_start_end_local_time() -> None:
end=datetime(2022, 9, 9, 11, 0, 0, tzinfo=timezone.utc),
)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(
summary=SUMMARY,
start=datetime(2022, 9, 9, 10, 0, 0, tzinfo=timezone.utc),
end=datetime(2022, 9, 9, 11, 0, 0),
)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(
summary=SUMMARY,
start=datetime(2022, 9, 9, 10, 0, 0),
Expand All @@ -212,10 +213,10 @@ def test_start_and_duration() -> None:
assert event.start == date(2022, 9, 9)
assert event.end == date(2022, 9, 12)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(summary=SUMMARY, start=date(2022, 9, 9), duration=timedelta(days=-3))

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseError):
Event(summary=SUMMARY, start=date(2022, 9, 9), duration=timedelta(seconds=60))

event = Event(
Expand Down
Loading

0 comments on commit 1e7ba71

Please sign in to comment.