Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove pydantic exceptions from the public interface #353

Merged
merged 1 commit into from
Nov 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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