Move google calendar integration to aiohttp (#70173)

* Use new aiohttp based google client library in gcal_sync.

* Use base url in tests for shorter string

* Remove unnecessary line of code

* Jump to gcal-sync-0.4.1

* Update tests/components/google/conftest.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update to gcal_sync 0.5.0 incorporating PR feedback

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-04-20 20:18:24 -07:00 committed by GitHub
parent b8369f79eb
commit 0e0c0ce22b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 239 additions and 286 deletions

View File

@ -8,7 +8,9 @@ import logging
from typing import Any from typing import Any
import aiohttp import aiohttp
from httplib2.error import ServerNotFoundError from gcal_sync.api import GoogleCalendarService
from gcal_sync.exceptions import ApiException
from gcal_sync.model import DateOrDatetime, Event
from oauth2client.file import Storage from oauth2client.file import Storage
import voluptuous as vol import voluptuous as vol
from voluptuous.error import Error as VoluptuousError from voluptuous.error import Error as VoluptuousError
@ -31,13 +33,14 @@ from homeassistant.exceptions import (
HomeAssistantError, HomeAssistantError,
) )
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import config_flow from . import config_flow
from .api import DeviceAuth, GoogleCalendarService from .api import ApiAuthImpl, DeviceAuth
from .const import ( from .const import (
CONF_CALENDAR_ACCESS, CONF_CALENDAR_ACCESS,
DATA_CONFIG, DATA_CONFIG,
@ -212,7 +215,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
"Required scopes are not available, reauth required" "Required scopes are not available, reauth required"
) )
calendar_service = GoogleCalendarService(hass, session) calendar_service = GoogleCalendarService(
ApiAuthImpl(async_get_clientsession(hass), session)
)
hass.data[DOMAIN][DATA_SERVICE] = calendar_service hass.data[DOMAIN][DATA_SERVICE] = calendar_service
await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service) await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service)
@ -263,11 +268,12 @@ async def async_setup_services(
async def _scan_for_calendars(call: ServiceCall) -> None: async def _scan_for_calendars(call: ServiceCall) -> None:
"""Scan for new calendars.""" """Scan for new calendars."""
try: try:
calendars = await calendar_service.async_list_calendars() result = await calendar_service.async_list_calendars()
except ServerNotFoundError as err: except ApiException as err:
raise HomeAssistantError(str(err)) from err raise HomeAssistantError(str(err)) from err
tasks = [] tasks = []
for calendar in calendars: for calendar_item in result.items:
calendar = calendar_item.dict(exclude_unset=True)
calendar[CONF_TRACK] = config[CONF_TRACK_NEW] calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
tasks.append( tasks.append(
hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
@ -278,8 +284,8 @@ async def async_setup_services(
async def _add_event(call: ServiceCall) -> None: async def _add_event(call: ServiceCall) -> None:
"""Add a new event to calendar.""" """Add a new event to calendar."""
start = {} start: DateOrDatetime | None = None
end = {} end: DateOrDatetime | None = None
if EVENT_IN in call.data: if EVENT_IN in call.data:
if EVENT_IN_DAYS in call.data[EVENT_IN]: if EVENT_IN_DAYS in call.data[EVENT_IN]:
@ -288,8 +294,8 @@ async def async_setup_services(
start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
end_in = start_in + timedelta(days=1) end_in = start_in + timedelta(days=1)
start = {"date": start_in.strftime("%Y-%m-%d")} start = DateOrDatetime(date=start_in)
end = {"date": end_in.strftime("%Y-%m-%d")} end = DateOrDatetime(date=end_in)
elif EVENT_IN_WEEKS in call.data[EVENT_IN]: elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
now = datetime.now() now = datetime.now()
@ -297,29 +303,34 @@ async def async_setup_services(
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
end_in = start_in + timedelta(days=1) end_in = start_in + timedelta(days=1)
start = {"date": start_in.strftime("%Y-%m-%d")} start = DateOrDatetime(date=start_in)
end = {"date": end_in.strftime("%Y-%m-%d")} end = DateOrDatetime(date=end_in)
elif EVENT_START_DATE in call.data: elif EVENT_START_DATE in call.data:
start = {"date": str(call.data[EVENT_START_DATE])} start = DateOrDatetime(date=call.data[EVENT_START_DATE])
end = {"date": str(call.data[EVENT_END_DATE])} end = DateOrDatetime(date=call.data[EVENT_END_DATE])
elif EVENT_START_DATETIME in call.data: elif EVENT_START_DATETIME in call.data:
start_dt = str( start_dt = call.data[EVENT_START_DATETIME]
call.data[EVENT_START_DATETIME].strftime("%Y-%m-%dT%H:%M:%S") end_dt = call.data[EVENT_END_DATETIME]
start = DateOrDatetime(
date_time=start_dt, timezone=str(hass.config.time_zone)
)
end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone))
if start is None or end is None:
raise ValueError(
"Missing required fields to set start or end date/datetime"
) )
end_dt = str(call.data[EVENT_END_DATETIME].strftime("%Y-%m-%dT%H:%M:%S"))
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
await calendar_service.async_create_event( await calendar_service.async_create_event(
call.data[EVENT_CALENDAR_ID], call.data[EVENT_CALENDAR_ID],
{ Event(
"summary": call.data[EVENT_SUMMARY], summary=call.data[EVENT_SUMMARY],
"description": call.data[EVENT_DESCRIPTION], description=call.data[EVENT_DESCRIPTION],
"start": start, start=start,
"end": end, end=end,
}, ),
) )
# Only expose the add event service if we have the correct permissions # Only expose the add event service if we have the correct permissions

View File

@ -8,13 +8,13 @@ import logging
import time import time
from typing import Any from typing import Any
from googleapiclient import discovery as google_discovery import aiohttp
from gcal_sync.auth import AbstractAuth
import oauth2client import oauth2client
from oauth2client.client import ( from oauth2client.client import (
Credentials, Credentials,
DeviceFlowInfo, DeviceFlowInfo,
FlowExchangeError, FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError, OAuth2DeviceCodeError,
OAuth2WebServerFlow, OAuth2WebServerFlow,
) )
@ -150,95 +150,19 @@ async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
return DeviceFlow(hass, oauth_flow, device_flow_info) return DeviceFlow(hass, oauth_flow, device_flow_info)
def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials: class ApiAuthImpl(AbstractAuth):
"""Convert a Home Assistant token to a Google API Credentials object.""" """Authentication implementation for google calendar api library."""
conf = hass.data[DOMAIN][DATA_CONFIG]
return OAuth2Credentials(
access_token=token["access_token"],
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
refresh_token=token["refresh_token"],
token_expiry=datetime.datetime.fromtimestamp(token["expires_at"]),
token_uri=oauth2client.GOOGLE_TOKEN_URI,
scopes=[conf[CONF_CALENDAR_ACCESS].scope],
user_agent=None,
)
def _api_time_format(date_time: datetime.datetime | None) -> str | None:
"""Convert a datetime to the api string format."""
return date_time.isoformat("T") if date_time else None
class GoogleCalendarService:
"""Calendar service interface to Google."""
def __init__( def __init__(
self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session self,
websession: aiohttp.ClientSession,
session: config_entry_oauth2_flow.OAuth2Session,
) -> None: ) -> None:
"""Init the Google Calendar service.""" """Init the Google Calendar client library auth implementation."""
self._hass = hass super().__init__(websession)
self._session = session self._session = session
async def _async_get_service(self) -> google_discovery.Resource: async def async_get_access_token(self) -> str:
"""Get the calendar service with valid credetnails.""" """Return a valid access token."""
await self._session.async_ensure_token_valid() await self._session.async_ensure_token_valid()
creds = _async_google_creds(self._hass, self._session.token) return self._session.token["access_token"]
def _build() -> google_discovery.Resource:
return google_discovery.build(
"calendar", "v3", credentials=creds, cache_discovery=False
)
return await self._hass.async_add_executor_job(_build)
async def async_list_calendars(
self,
) -> list[dict[str, Any]]:
"""Return the list of calendars the user has added to their list."""
service = await self._async_get_service()
def _list_calendars() -> list[dict[str, Any]]:
cal_list = service.calendarList()
return cal_list.list().execute()["items"]
return await self._hass.async_add_executor_job(_list_calendars)
async def async_create_event(
self, calendar_id: str, event: dict[str, Any]
) -> dict[str, Any]:
"""Return the list of calendars the user has added to their list."""
service = await self._async_get_service()
def _create_event() -> dict[str, Any]:
events = service.events()
return events.insert(calendarId=calendar_id, body=event).execute()
return await self._hass.async_add_executor_job(_create_event)
async def async_list_events(
self,
calendar_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
search: str | None = None,
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return the list of events."""
service = await self._async_get_service()
def _list_events() -> tuple[list[dict[str, Any]], str | None]:
events = service.events()
result = events.list(
calendarId=calendar_id,
timeMin=_api_time_format(start_time if start_time else dt.now()),
timeMax=_api_time_format(end_time),
q=search,
maxResults=EVENT_PAGE_SIZE,
pageToken=page_token,
singleEvents=True, # Flattens recurring events
orderBy="startTime",
).execute()
return (result["items"], result.get("nextPageToken"))
return await self._hass.async_add_executor_job(_list_events)

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from datetime import date, datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any from typing import Any
from httplib2 import ServerNotFoundError from gcal_sync.api import GoogleCalendarService, ListEventsRequest
from gcal_sync.exceptions import ApiException
from gcal_sync.model import Event
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
ENTITY_ID_FORMAT, ENTITY_ID_FORMAT,
@ -22,7 +24,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle, dt from homeassistant.util import Throttle
from . import ( from . import (
CONF_CAL_ID, CONF_CAL_ID,
@ -34,7 +36,6 @@ from . import (
DOMAIN, DOMAIN,
SERVICE_SCAN_CALENDARS, SERVICE_SCAN_CALENDARS,
) )
from .api import GoogleCalendarService
from .const import DISCOVER_CALENDAR from .const import DISCOVER_CALENDAR
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -147,77 +148,66 @@ class GoogleCalendarEntity(CalendarEntity):
"""Return the name of the entity.""" """Return the name of the entity."""
return self._name return self._name
def _event_filter(self, event: dict[str, Any]) -> bool: def _event_filter(self, event: Event) -> bool:
"""Return True if the event is visible.""" """Return True if the event is visible."""
if self._ignore_availability: if self._ignore_availability:
return True return True
return event.get(TRANSPARENCY, OPAQUE) == OPAQUE return event.transparency == OPAQUE
async def async_get_events( async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]: ) -> list[CalendarEvent]:
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
event_list: list[dict[str, Any]] = [] event_list: list[dict[str, Any]] = []
page_token: str | None = None
request = ListEventsRequest(
calendar_id=self._calendar_id,
start_time=start_date,
end_time=end_date,
search=self._search,
)
while True: while True:
try: try:
items, page_token = await self._calendar_service.async_list_events( result = await self._calendar_service.async_list_events(request)
self._calendar_id, except ApiException as err:
start_time=start_date,
end_time=end_date,
search=self._search,
page_token=page_token,
)
except ServerNotFoundError as err:
_LOGGER.error("Unable to connect to Google: %s", err) _LOGGER.error("Unable to connect to Google: %s", err)
return [] return []
event_list.extend(filter(self._event_filter, items)) event_list.extend(filter(self._event_filter, result.items))
if not page_token: if not result.page_token:
break break
request.page_token = result.page_token
return [_get_calendar_event(event) for event in event_list] return [_get_calendar_event(event) for event in event_list]
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the latest data.""" """Get the latest data."""
request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search)
try: try:
items, _ = await self._calendar_service.async_list_events( result = await self._calendar_service.async_list_events(request)
self._calendar_id, search=self._search except ApiException as err:
)
except ServerNotFoundError as err:
_LOGGER.error("Unable to connect to Google: %s", err) _LOGGER.error("Unable to connect to Google: %s", err)
return return
# Pick the first visible event and apply offset calculations. # Pick the first visible event and apply offset calculations.
valid_items = filter(self._event_filter, items) valid_items = filter(self._event_filter, result.items)
event = copy.deepcopy(next(valid_items, None)) event = copy.deepcopy(next(valid_items, None))
if event: if event:
(summary, offset) = extract_offset(event.get("summary", ""), self._offset) (event.summary, offset) = extract_offset(event.summary, self._offset)
event["summary"] = summary
self._event = _get_calendar_event(event) self._event = _get_calendar_event(event)
self._offset_value = offset self._offset_value = offset
else: else:
self._event = None self._event = None
def _get_date_or_datetime(date_dict: dict[str, str]) -> datetime | date: def _get_calendar_event(event: Event) -> CalendarEvent:
"""Convert a google calendar API response to a datetime or date object."""
if "date" in date_dict:
parsed_date = dt.parse_date(date_dict["date"])
assert parsed_date
return parsed_date
parsed_datetime = dt.parse_datetime(date_dict["dateTime"])
assert parsed_datetime
return parsed_datetime
def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent:
"""Return a CalendarEvent from an API event.""" """Return a CalendarEvent from an API event."""
return CalendarEvent( return CalendarEvent(
summary=event["summary"], summary=event.summary,
start=_get_date_or_datetime(event["start"]), start=event.start.value,
end=_get_date_or_datetime(event["end"]), end=event.end.value,
description=event.get("description"), description=event.description,
location=event.get("location"), location=event.location,
) )

View File

@ -4,11 +4,7 @@
"config_flow": true, "config_flow": true,
"dependencies": ["auth"], "dependencies": ["auth"],
"documentation": "https://www.home-assistant.io/integrations/calendar.google/", "documentation": "https://www.home-assistant.io/integrations/calendar.google/",
"requirements": [ "requirements": ["gcal-sync==0.5.0", "oauth2client==4.1.3"],
"google-api-python-client==2.38.0",
"httplib2==0.20.4",
"oauth2client==4.1.3"
],
"codeowners": ["@allenporter"], "codeowners": ["@allenporter"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"] "loggers": ["googleapiclient"]

View File

@ -678,6 +678,9 @@ gTTS==2.2.4
# homeassistant.components.garages_amsterdam # homeassistant.components.garages_amsterdam
garages-amsterdam==3.0.0 garages-amsterdam==3.0.0
# homeassistant.components.google
gcal-sync==0.5.0
# homeassistant.components.geniushub # homeassistant.components.geniushub
geniushub-client==0.6.30 geniushub-client==0.6.30
@ -718,9 +721,6 @@ goalzero==0.2.1
# homeassistant.components.goodwe # homeassistant.components.goodwe
goodwe==0.2.15 goodwe==0.2.15
# homeassistant.components.google
google-api-python-client==2.38.0
# homeassistant.components.google_pubsub # homeassistant.components.google_pubsub
google-cloud-pubsub==2.11.0 google-cloud-pubsub==2.11.0
@ -827,7 +827,6 @@ homepluscontrol==0.0.5
# homeassistant.components.horizon # homeassistant.components.horizon
horimote==0.4.1 horimote==0.4.1
# homeassistant.components.google
# homeassistant.components.remember_the_milk # homeassistant.components.remember_the_milk
httplib2==0.20.4 httplib2==0.20.4

View File

@ -475,6 +475,9 @@ gTTS==2.2.4
# homeassistant.components.garages_amsterdam # homeassistant.components.garages_amsterdam
garages-amsterdam==3.0.0 garages-amsterdam==3.0.0
# homeassistant.components.google
gcal-sync==0.5.0
# homeassistant.components.usgs_earthquakes_feed # homeassistant.components.usgs_earthquakes_feed
geojson_client==0.6 geojson_client==0.6
@ -509,9 +512,6 @@ goalzero==0.2.1
# homeassistant.components.goodwe # homeassistant.components.goodwe
goodwe==0.2.15 goodwe==0.2.15
# homeassistant.components.google
google-api-python-client==2.38.0
# homeassistant.components.google_pubsub # homeassistant.components.google_pubsub
google-cloud-pubsub==2.11.0 google-cloud-pubsub==2.11.0
@ -585,7 +585,6 @@ homematicip==1.0.2
# homeassistant.components.home_plus_control # homeassistant.components.home_plus_control
homepluscontrol==0.0.5 homepluscontrol==0.0.5
# homeassistant.components.google
# homeassistant.components.remember_the_milk # homeassistant.components.remember_the_milk
httplib2==0.20.4 httplib2==0.20.4

View File

@ -4,9 +4,9 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import datetime import datetime
from typing import Any, Generator, TypeVar from typing import Any, Generator, TypeVar
from unittest.mock import Mock, mock_open, patch from unittest.mock import mock_open, patch
from googleapiclient import discovery as google_discovery from gcal_sync.auth import API_BASE_URL
from oauth2client.client import Credentials, OAuth2Credentials from oauth2client.client import Credentials, OAuth2Credentials
import pytest import pytest
import yaml import yaml
@ -18,6 +18,7 @@ from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
ApiResult = Callable[[dict[str, Any]], None] ApiResult = Callable[[dict[str, Any]], None]
ComponentSetup = Callable[[], Awaitable[bool]] ComponentSetup = Callable[[], Awaitable[bool]]
@ -198,22 +199,21 @@ def mock_token_read(
storage.put(creds) storage.put(creds)
@pytest.fixture(autouse=True)
def calendar_resource() -> YieldFixture[google_discovery.Resource]:
"""Fixture to mock out the Google discovery API."""
with patch("homeassistant.components.google.api.google_discovery.build") as mock:
yield mock
@pytest.fixture @pytest.fixture
def mock_events_list( def mock_events_list(
calendar_resource: google_discovery.Resource, aioclient_mock: AiohttpClientMocker,
) -> Callable[[dict[str, Any]], None]: ) -> ApiResult:
"""Fixture to construct a fake event list API response.""" """Fixture to construct a fake event list API response."""
def _put_result(response: dict[str, Any]) -> None: def _put_result(
calendar_resource.return_value.events.return_value.list.return_value.execute.return_value = ( response: dict[str, Any], calendar_id: str = None, exc: Exception = None
response ) -> None:
if calendar_id is None:
calendar_id = CALENDAR_ID
aioclient_mock.get(
f"{API_BASE_URL}/calendars/{calendar_id}/events",
json=response,
exc=exc,
) )
return return
@ -235,13 +235,15 @@ def mock_events_list_items(
@pytest.fixture @pytest.fixture
def mock_calendars_list( def mock_calendars_list(
calendar_resource: google_discovery.Resource, aioclient_mock: AiohttpClientMocker,
) -> ApiResult: ) -> ApiResult:
"""Fixture to construct a fake calendar list API response.""" """Fixture to construct a fake calendar list API response."""
def _put_result(response: dict[str, Any]) -> None: def _put_result(response: dict[str, Any], exc=None) -> None:
calendar_resource.return_value.calendarList.return_value.list.return_value.execute.return_value = ( aioclient_mock.get(
response f"{API_BASE_URL}/users/me/calendarList",
json=response,
exc=exc,
) )
return return
@ -250,12 +252,17 @@ def mock_calendars_list(
@pytest.fixture @pytest.fixture
def mock_insert_event( def mock_insert_event(
calendar_resource: google_discovery.Resource, aioclient_mock: AiohttpClientMocker,
) -> Mock: ) -> Callable[[..., dict[str, Any]], None]:
"""Fixture to create a mock to capture new events added to the API.""" """Fixture for capturing event creation."""
insert_mock = Mock()
calendar_resource.return_value.events.return_value.insert = insert_mock def _expect_result(calendar_id: str = CALENDAR_ID) -> None:
return insert_mock aioclient_mock.post(
f"{API_BASE_URL}/calendars/{calendar_id}/events",
)
return
return _expect_result
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@ -8,7 +8,7 @@ from typing import Any
from unittest.mock import patch from unittest.mock import patch
import urllib import urllib
import httplib2 from aiohttp.client_exceptions import ClientError
import pytest import pytest
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
@ -302,16 +302,19 @@ async def test_missing_summary(hass, mock_events_list_items, component_setup):
async def test_update_error( async def test_update_error(
hass, calendar_resource, component_setup, test_api_calendar hass,
component_setup,
mock_calendars_list,
mock_events_list,
test_api_calendar,
aioclient_mock,
): ):
"""Test that the calendar update handles a server error.""" """Test that the calendar update handles a server error."""
now = dt_util.now() now = dt_util.now()
with patch("homeassistant.components.google.api.google_discovery.build") as mock: mock_calendars_list({"items": [test_api_calendar]})
mock.return_value.calendarList.return_value.list.return_value.execute.return_value = { mock_events_list(
"items": [test_api_calendar] {
}
mock.return_value.events.return_value.list.return_value.execute.return_value = {
"items": [ "items": [
{ {
**TEST_EVENT, **TEST_EVENT,
@ -324,7 +327,8 @@ async def test_update_error(
} }
] ]
} }
assert await component_setup() )
assert await component_setup()
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME assert state.name == TEST_ENTITY_NAME
@ -332,10 +336,11 @@ async def test_update_error(
# Advance time to avoid throttling # Advance time to avoid throttling
now += datetime.timedelta(minutes=30) now += datetime.timedelta(minutes=30)
with patch(
"homeassistant.components.google.api.google_discovery.build", aioclient_mock.clear_requests()
side_effect=httplib2.ServerNotFoundError("unit test"), mock_events_list({}, exc=ClientError())
), patch("homeassistant.util.utcnow", return_value=now):
with patch("homeassistant.util.utcnow", return_value=now):
async_fire_time_changed(hass, now) async_fire_time_changed(hass, now)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -346,10 +351,10 @@ async def test_update_error(
# Advance time beyond update/throttle point # Advance time beyond update/throttle point
now += datetime.timedelta(minutes=30) now += datetime.timedelta(minutes=30)
with patch(
"homeassistant.components.google.api.google_discovery.build" aioclient_mock.clear_requests()
) as mock, patch("homeassistant.util.utcnow", return_value=now): mock_events_list(
mock.return_value.events.return_value.list.return_value.execute.return_value = { {
"items": [ "items": [
{ {
**TEST_EVENT, **TEST_EVENT,
@ -362,6 +367,9 @@ async def test_update_error(
} }
] ]
} }
)
with patch("homeassistant.util.utcnow", return_value=now):
async_fire_time_changed(hass, now) async_fire_time_changed(hass, now)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -371,8 +379,11 @@ async def test_update_error(
assert state.state == "off" assert state.state == "off"
async def test_calendars_api(hass, hass_client, component_setup): async def test_calendars_api(
hass, hass_client, component_setup, mock_events_list_items
):
"""Test the Rest API returns the calendar.""" """Test the Rest API returns the calendar."""
mock_events_list_items([])
assert await component_setup() assert await component_setup()
client = await hass_client() client = await hass_client()
@ -388,14 +399,21 @@ async def test_calendars_api(hass, hass_client, component_setup):
async def test_http_event_api_failure( async def test_http_event_api_failure(
hass, hass_client, calendar_resource, component_setup hass,
hass_client,
component_setup,
mock_calendars_list,
mock_events_list,
aioclient_mock,
): ):
"""Test the Rest API response during a calendar failure.""" """Test the Rest API response during a calendar failure."""
mock_events_list({})
assert await component_setup() assert await component_setup()
client = await hass_client() client = await hass_client()
calendar_resource.side_effect = httplib2.ServerNotFoundError("unit test") aioclient_mock.clear_requests()
mock_events_list({}, exc=ClientError())
response = await client.get(upcoming_event_url()) response = await client.get(upcoming_event_url())
assert response.status == HTTPStatus.OK assert response.status == HTTPStatus.OK
@ -493,16 +511,14 @@ async def test_opaque_event(
async def test_scan_calendar_error( async def test_scan_calendar_error(
hass, hass,
calendar_resource,
component_setup, component_setup,
test_api_calendar, test_api_calendar,
mock_calendars_list,
): ):
"""Test that the calendar update handles a server error.""" """Test that the calendar update handles a server error."""
with patch(
"homeassistant.components.google.api.google_discovery.build", mock_calendars_list({}, exc=ClientError())
side_effect=httplib2.ServerNotFoundError("unit test"), assert await component_setup()
):
assert await component_setup()
assert not hass.states.get(TEST_ENTITY) assert not hass.states.get(TEST_ENTITY)

View File

@ -6,7 +6,7 @@ import datetime
import http import http
import time import time
from typing import Any from typing import Any
from unittest.mock import Mock, call, patch from unittest.mock import patch
import pytest import pytest
@ -134,10 +134,12 @@ async def test_calendar_yaml_error(
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_events_list: ApiResult,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test setup with yaml file not found.""" """Test setup with yaml file not found."""
mock_calendars_list({"items": [test_api_calendar]}) mock_calendars_list({"items": [test_api_calendar]})
mock_events_list({})
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()): with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
assert await component_setup() assert await component_setup()
@ -182,6 +184,7 @@ async def test_track_new(
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_events_list: ApiResult,
mock_calendars_yaml: None, mock_calendars_yaml: None,
expected_state: State, expected_state: State,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
@ -189,6 +192,7 @@ async def test_track_new(
"""Test behavior of configuration.yaml settings for tracking new calendars not in the config.""" """Test behavior of configuration.yaml settings for tracking new calendars not in the config."""
mock_calendars_list({"items": [test_api_calendar]}) mock_calendars_list({"items": [test_api_calendar]})
mock_events_list({})
assert await component_setup() assert await component_setup()
state = hass.states.get(TEST_API_ENTITY) state = hass.states.get(TEST_API_ENTITY)
@ -202,11 +206,13 @@ async def test_found_calendar_from_api(
mock_calendars_yaml: None, mock_calendars_yaml: None,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_events_list: ApiResult,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test finding a calendar from the API.""" """Test finding a calendar from the API."""
mock_calendars_list({"items": [test_api_calendar]}) mock_calendars_list({"items": [test_api_calendar]})
mock_events_list({})
assert await component_setup() assert await component_setup()
state = hass.states.get(TEST_API_ENTITY) state = hass.states.get(TEST_API_ENTITY)
@ -240,6 +246,7 @@ async def test_calendar_config_track_new(
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_yaml: None, mock_calendars_yaml: None,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
mock_events_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
calendars_config_track: bool, calendars_config_track: bool,
expected_state: State, expected_state: State,
@ -248,44 +255,35 @@ async def test_calendar_config_track_new(
"""Test calendar config that overrides whether or not a calendar is tracked.""" """Test calendar config that overrides whether or not a calendar is tracked."""
mock_calendars_list({"items": [test_api_calendar]}) mock_calendars_list({"items": [test_api_calendar]})
mock_events_list({})
assert await component_setup() assert await component_setup()
state = hass.states.get(TEST_YAML_ENTITY) state = hass.states.get(TEST_YAML_ENTITY)
assert_state(state, expected_state) assert_state(state, expected_state)
async def test_add_event( async def test_add_event_missing_required_fields(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test service call that adds an event.""" """Test service call that adds an event missing required fields."""
assert await component_setup() assert await component_setup()
await hass.services.async_call( with pytest.raises(ValueError):
DOMAIN, await hass.services.async_call(
SERVICE_ADD_EVENT, DOMAIN,
{ SERVICE_ADD_EVENT,
"calendar_id": CALENDAR_ID, {
"summary": "Summary", "calendar_id": CALENDAR_ID,
"description": "Description", "summary": "Summary",
}, "description": "Description",
blocking=True, },
) blocking=True,
mock_insert_event.assert_called() )
assert mock_insert_event.mock_calls[0] == call(
calendarId=CALENDAR_ID,
body={
"summary": "Summary",
"description": "Description",
"start": {},
"end": {},
},
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -308,17 +306,27 @@ async def test_add_event_date_in_x(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
mock_insert_event: Callable[[..., dict[str, Any]], None],
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
date_fields: dict[str, Any], date_fields: dict[str, Any],
start_timedelta: datetime.timedelta, start_timedelta: datetime.timedelta,
end_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Test service call that adds an event with various time ranges.""" """Test service call that adds an event with various time ranges."""
mock_calendars_list({})
assert await component_setup() assert await component_setup()
now = datetime.datetime.now()
start_date = now + start_timedelta
end_date = now + end_timedelta
mock_insert_event(
calendar_id=CALENDAR_ID,
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_ADD_EVENT, SERVICE_ADD_EVENT,
@ -330,38 +338,36 @@ async def test_add_event_date_in_x(
}, },
blocking=True, blocking=True,
) )
mock_insert_event.assert_called() assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[1][2] == {
now = datetime.datetime.now() "summary": "Summary",
start_date = now + start_timedelta "description": "Description",
end_date = now + end_timedelta "start": {"date": start_date.date().isoformat()},
"end": {"date": end_date.date().isoformat()},
assert mock_insert_event.mock_calls[0] == call( }
calendarId=CALENDAR_ID,
body={
"summary": "Summary",
"description": "Description",
"start": {"date": start_date.date().isoformat()},
"end": {"date": end_date.date().isoformat()},
},
)
async def test_add_event_date( async def test_add_event_date(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
mock_insert_event: Mock, mock_insert_event: Callable[[str, dict[str, Any]], None],
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Test service call that sets a date range.""" """Test service call that sets a date range."""
mock_calendars_list({})
assert await component_setup() assert await component_setup()
now = utcnow() now = utcnow()
today = now.date() today = now.date()
end_date = today + datetime.timedelta(days=2) end_date = today + datetime.timedelta(days=2)
mock_insert_event(
calendar_id=CALENDAR_ID,
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_ADD_EVENT, SERVICE_ADD_EVENT,
@ -374,35 +380,37 @@ async def test_add_event_date(
}, },
blocking=True, blocking=True,
) )
mock_insert_event.assert_called() assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[1][2] == {
assert mock_insert_event.mock_calls[0] == call( "summary": "Summary",
calendarId=CALENDAR_ID, "description": "Description",
body={ "start": {"date": today.isoformat()},
"summary": "Summary", "end": {"date": end_date.isoformat()},
"description": "Description", }
"start": {"date": today.isoformat()},
"end": {"date": end_date.isoformat()},
},
)
async def test_add_event_date_time( async def test_add_event_date_time(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
mock_insert_event: Callable[[str, dict[str, Any]], None],
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Test service call that adds an event with a date time range.""" """Test service call that adds an event with a date time range."""
mock_calendars_list({})
assert await component_setup() assert await component_setup()
start_datetime = datetime.datetime.now() start_datetime = datetime.datetime.now()
delta = datetime.timedelta(days=3, hours=3) delta = datetime.timedelta(days=3, hours=3)
end_datetime = start_datetime + delta end_datetime = start_datetime + delta
mock_insert_event(
calendar_id=CALENDAR_ID,
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_ADD_EVENT, SERVICE_ADD_EVENT,
@ -415,34 +423,32 @@ async def test_add_event_date_time(
}, },
blocking=True, blocking=True,
) )
mock_insert_event.assert_called() assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[1][2] == {
assert mock_insert_event.mock_calls[0] == call( "summary": "Summary",
calendarId=CALENDAR_ID, "description": "Description",
body={ "start": {
"summary": "Summary", "dateTime": start_datetime.isoformat(timespec="seconds"),
"description": "Description", "timeZone": "America/Regina",
"start": {
"dateTime": start_datetime.isoformat(timespec="seconds"),
"timeZone": "America/Regina",
},
"end": {
"dateTime": end_datetime.isoformat(timespec="seconds"),
"timeZone": "America/Regina",
},
}, },
) "end": {
"dateTime": end_datetime.isoformat(timespec="seconds"),
"timeZone": "America/Regina",
},
}
async def test_scan_calendars( async def test_scan_calendars(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], mock_events_list: ApiResult,
setup_config_entry: MockConfigEntry, setup_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Test finding a calendar from the API.""" """Test finding a calendar from the API."""
mock_calendars_list({"items": []})
assert await component_setup() assert await component_setup()
calendar_1 = { calendar_1 = {
@ -454,7 +460,9 @@ async def test_scan_calendars(
"summary": "Calendar 2", "summary": "Calendar 2",
} }
aioclient_mock.clear_requests()
mock_calendars_list({"items": [calendar_1]}) mock_calendars_list({"items": [calendar_1]})
mock_events_list({}, calendar_id="calendar-id-1")
await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -464,7 +472,10 @@ async def test_scan_calendars(
assert state.state == STATE_OFF assert state.state == STATE_OFF
assert not hass.states.get("calendar.calendar_2") assert not hass.states.get("calendar.calendar_2")
aioclient_mock.clear_requests()
mock_calendars_list({"items": [calendar_1, calendar_2]}) mock_calendars_list({"items": [calendar_1, calendar_2]})
mock_events_list({}, calendar_id="calendar-id-1")
mock_events_list({}, calendar_id="calendar-id-2")
await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True)
await hass.async_block_till_done() await hass.async_block_till_done()