Skip to content

Commit

Permalink
Remove pydantic exceptions from the public interface
Browse files Browse the repository at this point in the history
  • Loading branch information
allenporter committed Nov 12, 2023
1 parent 59db493 commit 8bdc667
Show file tree
Hide file tree
Showing 5 changed files with 62 additions and 58 deletions.
60 changes: 25 additions & 35 deletions gcal_sync/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,19 @@
from urllib.request import pathname2url

try:
from pydantic.v1 import BaseModel, Field, ValidationError, root_validator, validator
from pydantic.v1 import Field, root_validator, validator
except ImportError:
from pydantic import ( # type: ignore
BaseModel,
Field,
ValidationError,
root_validator,
validator,
)

from .auth import AbstractAuth
from .const import ITEMS
from .exceptions import ApiException
from .model import (
EVENT_FIELDS,
CalendarBaseModel,
Calendar,
CalendarBasic,
Event,
Expand Down Expand Up @@ -83,7 +81,7 @@
INSTANCES_URL = "calendars/{calendar_id}/events/{event_id}/instances"


class SyncableRequest(BaseModel):
class SyncableRequest(CalendarBaseModel):
"""Base class for a request that supports sync."""

page_token: Optional[str] = Field(default=None, alias="pageToken")
Expand All @@ -93,7 +91,7 @@ class SyncableRequest(BaseModel):
"""Token obtained from the last page of results of a previous request."""


class SyncableResponse(BaseModel):
class SyncableResponse(CalendarBaseModel):
"""Base class for an API response that supports sync."""

page_token: Optional[str] = Field(default=None, alias="nextPageToken")
Expand Down Expand Up @@ -216,7 +214,7 @@ class Boolean(str, enum.Enum):
FALSE = "false"


class _RawListEventsRequest(BaseModel):
class _RawListEventsRequest(CalendarBaseModel):
"""Api request to list events.
This is used internally to have separate validation between list event requests
Expand Down Expand Up @@ -337,14 +335,14 @@ async def async_list_calendars(
if request:
params = json.loads(request.json(exclude_none=True, by_alias=True))
result = await self._auth.get_json(CALENDAR_LIST_URL, params=params)
return CalendarListResponse.parse_obj(result)
return CalendarListResponse(**result)

async def async_get_calendar(self, calendar_id: str) -> CalendarBasic:
"""Return the calendar with the specified id."""
result = await self._auth.get_json(
CALENDAR_GET_URL.format(calendar_id=calendar_id)
)
return CalendarBasic.parse_obj(result)
return CalendarBasic(**result)

async def async_get_event(self, calendar_id: str, event_id: str) -> Event:
"""Return an event based on the event id."""
Expand All @@ -353,7 +351,7 @@ async def async_get_event(self, calendar_id: str, event_id: str) -> Event:
calendar_id=pathname2url(calendar_id), event_id=pathname2url(event_id)
)
)
return Event.parse_obj(result)
return Event(**result)

async def async_list_events(
self,
Expand Down Expand Up @@ -385,11 +383,7 @@ async def async_list_events_page(
params=params,
)
_ListEventsResponseModel.update_forward_refs()
try:
return _ListEventsResponseModel.parse_obj(result)
except ValidationError as err:
_LOGGER.debug("Unable to parse result: %s", result)
raise ApiException("Error parsing API response") from err
return _ListEventsResponseModel(**result)

async def async_create_event(
self,
Expand Down Expand Up @@ -432,14 +426,14 @@ async def async_delete_event(
)


class LocalCalendarListResponse(BaseModel):
class LocalCalendarListResponse(CalendarBaseModel):
"""Api response containing a list of calendars."""

calendars: List[Calendar] = []
"""The list of calendars."""


class LocalListEventsRequest(BaseModel):
class LocalListEventsRequest(CalendarBaseModel):
"""Api request to list events from the local event store."""

start_time: datetime.datetime = Field(default_factory=now)
Expand All @@ -459,7 +453,7 @@ class Config:
allow_population_by_field_name = True


class LocalListEventsResponse(BaseModel):
class LocalListEventsResponse(CalendarBaseModel):
"""Api response containing a list of events."""

events: List[Event] = Field(default_factory=list)
Expand All @@ -482,7 +476,7 @@ async def async_list_calendars(
items = store_data.get(ITEMS, {})

return LocalCalendarListResponse(
calendars=[Calendar.parse_obj(item) for item in items.values()]
calendars=[Calendar(**item) for item in items.values()]
)


Expand Down Expand Up @@ -546,7 +540,7 @@ async def async_get_timeline(
events_data = await self._lookup_events_data()
_LOGGER.debug("Created timeline of %d events", len(events_data))
return calendar_timeline(
[Event.parse_obj(data) for data in events_data.values()],
[Event(**data) for data in events_data.values()],
tzinfo if tzinfo else datetime.timezone.utc,
)

Expand Down Expand Up @@ -610,13 +604,11 @@ async def async_delete_event(

if recurrence_range == Range.NONE:
# A single recurrence instance is removed, marked as cancelled
cancelled_event = Event.parse_obj(
{
"id": event_id, # Event instance
"status": EventStatusEnum.CANCELLED,
"start": event.start,
"end": event.end,
}
cancelled_event = Event(
id=event_id, # Event instance
status=EventStatusEnum.CANCELLED,
start=event.start,
end=event.end,
)
body = json.loads(cancelled_event.json(exclude_unset=True, by_alias=True))
del body["start"]
Expand All @@ -639,13 +631,11 @@ async def async_delete_event(
# safe and works for both dates and datetimes.
recur.rrule[0].count = 0
recur.rrule[0].until = synthetic_event_id.dtstart - datetime.timedelta(days=1)
updated_event = Event.parse_obj(
{
"id": event.id, # Primary event
"recurrence": recur.as_recurrence(),
"start": event.start,
"end": event.end,
}
updated_event = Event(
id=event.id, # Primary event
recurrence=recur.as_recurrence(),
start=event.start,
end=event.end,
)
body = json.loads(updated_event.json(exclude_unset=True, by_alias=True))
del body["start"]
Expand All @@ -663,5 +653,5 @@ async def _lookup_ical_uuid(self, ical_uuid: str) -> Event | None:
events_data = await self._lookup_events_data()
for data in events_data.values():
if (event_uuid := data.get("ical_uuid")) and event_uuid == ical_uuid:
return Event.parse_obj(data)
return Event(**data)
return None
4 changes: 4 additions & 0 deletions gcal_sync/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ class InvalidSyncTokenException(ApiException):

class ApiForbiddenException(ApiException):
"""Raised due to permission errors talking to API."""


class CalendarParseException(ApiException):
"""Raised when parsing a calendar event fails."""
35 changes: 25 additions & 10 deletions gcal_sync/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
from ical.types.recur import Frequency, Recur

try:
from pydantic.v1 import BaseModel, Field, root_validator
from pydantic.v1 import BaseModel, Field, root_validator, ValidationError
except ImportError:
from pydantic import BaseModel, Field, root_validator # type: ignore
from pydantic import BaseModel, Field, root_validator, ValidationError # type: ignore

from .exceptions import CalendarParseException

__all__ = [
"Calendar",
Expand Down Expand Up @@ -75,7 +77,17 @@ def is_writer(self) -> bool:
return self in (AccessRole.WRITER, AccessRole.OWNER)


class Calendar(BaseModel):
class CalendarBaseModel(BaseModel):
"""Base class for calendar models."""

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


class Calendar(CalendarBaseModel):
"""Metadata associated with a calendar from the CalendarList API."""

id: str
Expand Down Expand Up @@ -108,7 +120,7 @@ class Config:
allow_population_by_field_name = True


class CalendarBasic(BaseModel):
class CalendarBasic(CalendarBaseModel):
"""Metadata associated with a calendar from the Get API."""

id: str
Expand All @@ -132,7 +144,7 @@ class Config:
allow_population_by_field_name = True


class DateOrDatetime(BaseModel):
class DateOrDatetime(CalendarBaseModel):
"""A date or datetime."""

date: Optional[datetime.date] = Field(default=None)
Expand Down Expand Up @@ -262,7 +274,7 @@ class ResponseStatus(str, Enum):
"""The attendee has accepted the invitation."""


class Attendee(BaseModel):
class Attendee(CalendarBaseModel):
"""An attendee of an event."""

id: Optional[str] = None
Expand Down Expand Up @@ -413,7 +425,10 @@ def from_recurrence(cls, recurrence: list[str]) -> "Recurrence":
]
)
component = parse_content("\n".join(content))
return cls.parse_obj(component[0].as_dict())
try:
return cls.parse_obj(component[0].as_dict())
except ValidationError as err:
raise CalendarParseException(err) from err

def as_rrule(
self, dtstart: datetime.date | datetime.datetime
Expand Down Expand Up @@ -448,7 +463,7 @@ class ReminderMethod(str, Enum):
"""Reminders are sent via a UI popup."""


class ReminderOverride(BaseModel):
class ReminderOverride(CalendarBaseModel):
"""Reminder settings to use instead of calendar default."""

method: ReminderMethod
Expand All @@ -458,7 +473,7 @@ class ReminderOverride(BaseModel):
"""Number of minutes before the start of the event to trigger."""


class Reminders(BaseModel):
class Reminders(CalendarBaseModel):
"""Information about the event's reminders for the authenticated user."""

use_default: bool = Field(alias="useDefault", default=True)
Expand All @@ -472,7 +487,7 @@ class Reminders(BaseModel):
"""


class Event(BaseModel):
class Event(CalendarBaseModel):
"""A single event on a calendar."""

id: Optional[str] = None
Expand Down
3 changes: 1 addition & 2 deletions gcal_sync/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ def calendar_timeline(
normal_events: list[Event] = []
recurring: list[Event] = []
recurring_skip: dict[str, set[datetime.date | datetime.datetime]] = {}
for data in events:
event = Event.parse_obj(data)
for event in events:
if event.recurring_event_id and event.original_start_time:
# The API returned a one-off instance of a recurring event. Keep track
# of the original start time which is used to filter out from the
Expand Down
18 changes: 7 additions & 11 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@

import pytest

try:
from pydantic.v1 import ValidationError
except ImportError:
from pydantic import ValidationError # type: ignore

from gcal_sync.model import (
EVENT_FIELDS,
ID_DELIM,
Expand All @@ -29,6 +24,7 @@
SyntheticEventId,
VisibilityEnum,
)
from gcal_sync.exceptions import CalendarParseException

SUMMARY = "test summary"
LOS_ANGELES = zoneinfo.ZoneInfo("America/Los_Angeles")
Expand Down Expand Up @@ -168,23 +164,23 @@ def test_invalid_datetime() -> None:
},
}

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseException):
Event.parse_obj(
{
**base_event,
"start": {},
}
)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseException):
Event.parse_obj(
{
**base_event,
"start": {"dateTime": "invalid-datetime"},
}
)

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseException):
Event.parse_obj(
{
**base_event,
Expand Down Expand Up @@ -392,7 +388,7 @@ def test_event_cancelled() -> None:
def test_required_fields() -> None:
"""Exercise required fields for normal non-deleted events."""

with pytest.raises(ValidationError):
with pytest.raises(CalendarParseException):
Event.parse_obj(
{
"id": "some-event-id",
Expand Down Expand Up @@ -621,7 +617,7 @@ def test_comparisons(
def test_invalid_rrule_until_format() -> None:
"""Test invalid RRULE parsing."""
with pytest.raises(
ValidationError, match=r"Recurrence rule had unexpected format.*"
CalendarParseException, match=r"Recurrence rule had unexpected format.*"
):
Event.parse_obj(
{
Expand All @@ -636,7 +632,7 @@ def test_invalid_rrule_until_format() -> None:
def test_invalid_rrule_until_time() -> None:
"""Test invalid RRULE parsing."""
with pytest.raises(
ValidationError, match=r"Expected value to match DATE pattern.*"
CalendarParseException, match=r"Expected value to match DATE pattern.*"
):
Event.parse_obj(
{
Expand Down

0 comments on commit 8bdc667

Please sign in to comment.