From 8bdc6672edc6a3b02728fc09b03b0779eb14b8bd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 21:07:01 -0800 Subject: [PATCH] Remove pydantic exceptions from the public interface --- gcal_sync/api.py | 60 +++++++++++++++++------------------------ gcal_sync/exceptions.py | 4 +++ gcal_sync/model.py | 35 +++++++++++++++++------- gcal_sync/timeline.py | 3 +-- tests/test_model.py | 18 +++++-------- 5 files changed, 62 insertions(+), 58 deletions(-) diff --git a/gcal_sync/api.py b/gcal_sync/api.py index 8073423..e79a79c 100644 --- a/gcal_sync/api.py +++ b/gcal_sync/api.py @@ -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, @@ -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") @@ -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") @@ -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 @@ -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.""" @@ -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, @@ -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, @@ -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) @@ -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) @@ -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()] ) @@ -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, ) @@ -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"] @@ -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"] @@ -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 diff --git a/gcal_sync/exceptions.py b/gcal_sync/exceptions.py index 770b2c1..1b39651 100644 --- a/gcal_sync/exceptions.py +++ b/gcal_sync/exceptions.py @@ -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.""" diff --git a/gcal_sync/model.py b/gcal_sync/model.py index a9c9875..a5a552b 100644 --- a/gcal_sync/model.py +++ b/gcal_sync/model.py @@ -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", @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 @@ -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) @@ -472,7 +487,7 @@ class Reminders(BaseModel): """ -class Event(BaseModel): +class Event(CalendarBaseModel): """A single event on a calendar.""" id: Optional[str] = None diff --git a/gcal_sync/timeline.py b/gcal_sync/timeline.py index 11f6e14..61ed1d5 100644 --- a/gcal_sync/timeline.py +++ b/gcal_sync/timeline.py @@ -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 diff --git a/tests/test_model.py b/tests/test_model.py index 5f9b862..985218a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -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, @@ -29,6 +24,7 @@ SyntheticEventId, VisibilityEnum, ) +from gcal_sync.exceptions import CalendarParseException SUMMARY = "test summary" LOS_ANGELES = zoneinfo.ZoneInfo("America/Los_Angeles") @@ -168,7 +164,7 @@ def test_invalid_datetime() -> None: }, } - with pytest.raises(ValidationError): + with pytest.raises(CalendarParseException): Event.parse_obj( { **base_event, @@ -176,7 +172,7 @@ def test_invalid_datetime() -> None: } ) - with pytest.raises(ValidationError): + with pytest.raises(CalendarParseException): Event.parse_obj( { **base_event, @@ -184,7 +180,7 @@ def test_invalid_datetime() -> None: } ) - with pytest.raises(ValidationError): + with pytest.raises(CalendarParseException): Event.parse_obj( { **base_event, @@ -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", @@ -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( { @@ -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( {