mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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:
@ -8,7 +8,9 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
import voluptuous as vol
|
||||
from voluptuous.error import Error as VoluptuousError
|
||||
@ -31,13 +33,14 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import config_flow
|
||||
from .api import DeviceAuth, GoogleCalendarService
|
||||
from .api import ApiAuthImpl, DeviceAuth
|
||||
from .const import (
|
||||
CONF_CALENDAR_ACCESS,
|
||||
DATA_CONFIG,
|
||||
@ -212,7 +215,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"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
|
||||
|
||||
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:
|
||||
"""Scan for new calendars."""
|
||||
try:
|
||||
calendars = await calendar_service.async_list_calendars()
|
||||
except ServerNotFoundError as err:
|
||||
result = await calendar_service.async_list_calendars()
|
||||
except ApiException as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
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]
|
||||
tasks.append(
|
||||
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:
|
||||
"""Add a new event to calendar."""
|
||||
start = {}
|
||||
end = {}
|
||||
start: DateOrDatetime | None = None
|
||||
end: DateOrDatetime | None = None
|
||||
|
||||
if EVENT_IN in call.data:
|
||||
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])
|
||||
end_in = start_in + timedelta(days=1)
|
||||
|
||||
start = {"date": start_in.strftime("%Y-%m-%d")}
|
||||
end = {"date": end_in.strftime("%Y-%m-%d")}
|
||||
start = DateOrDatetime(date=start_in)
|
||||
end = DateOrDatetime(date=end_in)
|
||||
|
||||
elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
|
||||
now = datetime.now()
|
||||
@ -297,29 +303,34 @@ async def async_setup_services(
|
||||
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
|
||||
end_in = start_in + timedelta(days=1)
|
||||
|
||||
start = {"date": start_in.strftime("%Y-%m-%d")}
|
||||
end = {"date": end_in.strftime("%Y-%m-%d")}
|
||||
start = DateOrDatetime(date=start_in)
|
||||
end = DateOrDatetime(date=end_in)
|
||||
|
||||
elif EVENT_START_DATE in call.data:
|
||||
start = {"date": str(call.data[EVENT_START_DATE])}
|
||||
end = {"date": str(call.data[EVENT_END_DATE])}
|
||||
start = DateOrDatetime(date=call.data[EVENT_START_DATE])
|
||||
end = DateOrDatetime(date=call.data[EVENT_END_DATE])
|
||||
|
||||
elif EVENT_START_DATETIME in call.data:
|
||||
start_dt = str(
|
||||
call.data[EVENT_START_DATETIME].strftime("%Y-%m-%dT%H:%M:%S")
|
||||
start_dt = call.data[EVENT_START_DATETIME]
|
||||
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(
|
||||
call.data[EVENT_CALENDAR_ID],
|
||||
{
|
||||
"summary": call.data[EVENT_SUMMARY],
|
||||
"description": call.data[EVENT_DESCRIPTION],
|
||||
"start": start,
|
||||
"end": end,
|
||||
},
|
||||
Event(
|
||||
summary=call.data[EVENT_SUMMARY],
|
||||
description=call.data[EVENT_DESCRIPTION],
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
)
|
||||
|
||||
# Only expose the add event service if we have the correct permissions
|
||||
|
@ -8,13 +8,13 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from googleapiclient import discovery as google_discovery
|
||||
import aiohttp
|
||||
from gcal_sync.auth import AbstractAuth
|
||||
import oauth2client
|
||||
from oauth2client.client import (
|
||||
Credentials,
|
||||
DeviceFlowInfo,
|
||||
FlowExchangeError,
|
||||
OAuth2Credentials,
|
||||
OAuth2DeviceCodeError,
|
||||
OAuth2WebServerFlow,
|
||||
)
|
||||
@ -150,95 +150,19 @@ async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
|
||||
return DeviceFlow(hass, oauth_flow, device_flow_info)
|
||||
|
||||
|
||||
def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials:
|
||||
"""Convert a Home Assistant token to a Google API Credentials object."""
|
||||
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."""
|
||||
class ApiAuthImpl(AbstractAuth):
|
||||
"""Authentication implementation for google calendar api library."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session
|
||||
self,
|
||||
websession: aiohttp.ClientSession,
|
||||
session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Init the Google Calendar service."""
|
||||
self._hass = hass
|
||||
"""Init the Google Calendar client library auth implementation."""
|
||||
super().__init__(websession)
|
||||
self._session = session
|
||||
|
||||
async def _async_get_service(self) -> google_discovery.Resource:
|
||||
"""Get the calendar service with valid credetnails."""
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self._session.async_ensure_token_valid()
|
||||
creds = _async_google_creds(self._hass, self._session.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)
|
||||
return self._session.token["access_token"]
|
||||
|
@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
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 (
|
||||
ENTITY_ID_FORMAT,
|
||||
@ -22,7 +24,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import Throttle, dt
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import (
|
||||
CONF_CAL_ID,
|
||||
@ -34,7 +36,6 @@ from . import (
|
||||
DOMAIN,
|
||||
SERVICE_SCAN_CALENDARS,
|
||||
)
|
||||
from .api import GoogleCalendarService
|
||||
from .const import DISCOVER_CALENDAR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -147,77 +148,66 @@ class GoogleCalendarEntity(CalendarEntity):
|
||||
"""Return the name of the entity."""
|
||||
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."""
|
||||
if self._ignore_availability:
|
||||
return True
|
||||
return event.get(TRANSPARENCY, OPAQUE) == OPAQUE
|
||||
return event.transparency == OPAQUE
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
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:
|
||||
try:
|
||||
items, page_token = await self._calendar_service.async_list_events(
|
||||
self._calendar_id,
|
||||
start_time=start_date,
|
||||
end_time=end_date,
|
||||
search=self._search,
|
||||
page_token=page_token,
|
||||
)
|
||||
except ServerNotFoundError as err:
|
||||
result = await self._calendar_service.async_list_events(request)
|
||||
except ApiException as err:
|
||||
_LOGGER.error("Unable to connect to Google: %s", err)
|
||||
return []
|
||||
|
||||
event_list.extend(filter(self._event_filter, items))
|
||||
if not page_token:
|
||||
event_list.extend(filter(self._event_filter, result.items))
|
||||
if not result.page_token:
|
||||
break
|
||||
|
||||
request.page_token = result.page_token
|
||||
|
||||
return [_get_calendar_event(event) for event in event_list]
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest data."""
|
||||
request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search)
|
||||
try:
|
||||
items, _ = await self._calendar_service.async_list_events(
|
||||
self._calendar_id, search=self._search
|
||||
)
|
||||
except ServerNotFoundError as err:
|
||||
result = await self._calendar_service.async_list_events(request)
|
||||
except ApiException as err:
|
||||
_LOGGER.error("Unable to connect to Google: %s", err)
|
||||
return
|
||||
|
||||
# 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))
|
||||
if event:
|
||||
(summary, offset) = extract_offset(event.get("summary", ""), self._offset)
|
||||
event["summary"] = summary
|
||||
(event.summary, offset) = extract_offset(event.summary, self._offset)
|
||||
self._event = _get_calendar_event(event)
|
||||
self._offset_value = offset
|
||||
else:
|
||||
self._event = None
|
||||
|
||||
|
||||
def _get_date_or_datetime(date_dict: dict[str, str]) -> datetime | date:
|
||||
"""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:
|
||||
def _get_calendar_event(event: Event) -> CalendarEvent:
|
||||
"""Return a CalendarEvent from an API event."""
|
||||
return CalendarEvent(
|
||||
summary=event["summary"],
|
||||
start=_get_date_or_datetime(event["start"]),
|
||||
end=_get_date_or_datetime(event["end"]),
|
||||
description=event.get("description"),
|
||||
location=event.get("location"),
|
||||
summary=event.summary,
|
||||
start=event.start.value,
|
||||
end=event.end.value,
|
||||
description=event.description,
|
||||
location=event.location,
|
||||
)
|
||||
|
@ -4,11 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["auth"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
||||
"requirements": [
|
||||
"google-api-python-client==2.38.0",
|
||||
"httplib2==0.20.4",
|
||||
"oauth2client==4.1.3"
|
||||
],
|
||||
"requirements": ["gcal-sync==0.5.0", "oauth2client==4.1.3"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"]
|
||||
|
@ -678,6 +678,9 @@ gTTS==2.2.4
|
||||
# homeassistant.components.garages_amsterdam
|
||||
garages-amsterdam==3.0.0
|
||||
|
||||
# homeassistant.components.google
|
||||
gcal-sync==0.5.0
|
||||
|
||||
# homeassistant.components.geniushub
|
||||
geniushub-client==0.6.30
|
||||
|
||||
@ -718,9 +721,6 @@ goalzero==0.2.1
|
||||
# homeassistant.components.goodwe
|
||||
goodwe==0.2.15
|
||||
|
||||
# homeassistant.components.google
|
||||
google-api-python-client==2.38.0
|
||||
|
||||
# homeassistant.components.google_pubsub
|
||||
google-cloud-pubsub==2.11.0
|
||||
|
||||
@ -827,7 +827,6 @@ homepluscontrol==0.0.5
|
||||
# homeassistant.components.horizon
|
||||
horimote==0.4.1
|
||||
|
||||
# homeassistant.components.google
|
||||
# homeassistant.components.remember_the_milk
|
||||
httplib2==0.20.4
|
||||
|
||||
|
@ -475,6 +475,9 @@ gTTS==2.2.4
|
||||
# homeassistant.components.garages_amsterdam
|
||||
garages-amsterdam==3.0.0
|
||||
|
||||
# homeassistant.components.google
|
||||
gcal-sync==0.5.0
|
||||
|
||||
# homeassistant.components.usgs_earthquakes_feed
|
||||
geojson_client==0.6
|
||||
|
||||
@ -509,9 +512,6 @@ goalzero==0.2.1
|
||||
# homeassistant.components.goodwe
|
||||
goodwe==0.2.15
|
||||
|
||||
# homeassistant.components.google
|
||||
google-api-python-client==2.38.0
|
||||
|
||||
# homeassistant.components.google_pubsub
|
||||
google-cloud-pubsub==2.11.0
|
||||
|
||||
@ -585,7 +585,6 @@ homematicip==1.0.2
|
||||
# homeassistant.components.home_plus_control
|
||||
homepluscontrol==0.0.5
|
||||
|
||||
# homeassistant.components.google
|
||||
# homeassistant.components.remember_the_milk
|
||||
httplib2==0.20.4
|
||||
|
||||
|
@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
import datetime
|
||||
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
|
||||
import pytest
|
||||
import yaml
|
||||
@ -18,6 +18,7 @@ from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
ApiResult = Callable[[dict[str, Any]], None]
|
||||
ComponentSetup = Callable[[], Awaitable[bool]]
|
||||
@ -198,22 +199,21 @@ def mock_token_read(
|
||||
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
|
||||
def mock_events_list(
|
||||
calendar_resource: google_discovery.Resource,
|
||||
) -> Callable[[dict[str, Any]], None]:
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> ApiResult:
|
||||
"""Fixture to construct a fake event list API response."""
|
||||
|
||||
def _put_result(response: dict[str, Any]) -> None:
|
||||
calendar_resource.return_value.events.return_value.list.return_value.execute.return_value = (
|
||||
response
|
||||
def _put_result(
|
||||
response: dict[str, Any], calendar_id: str = None, exc: Exception = None
|
||||
) -> 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
|
||||
|
||||
@ -235,13 +235,15 @@ def mock_events_list_items(
|
||||
|
||||
@pytest.fixture
|
||||
def mock_calendars_list(
|
||||
calendar_resource: google_discovery.Resource,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> ApiResult:
|
||||
"""Fixture to construct a fake calendar list API response."""
|
||||
|
||||
def _put_result(response: dict[str, Any]) -> None:
|
||||
calendar_resource.return_value.calendarList.return_value.list.return_value.execute.return_value = (
|
||||
response
|
||||
def _put_result(response: dict[str, Any], exc=None) -> None:
|
||||
aioclient_mock.get(
|
||||
f"{API_BASE_URL}/users/me/calendarList",
|
||||
json=response,
|
||||
exc=exc,
|
||||
)
|
||||
return
|
||||
|
||||
@ -250,12 +252,17 @@ def mock_calendars_list(
|
||||
|
||||
@pytest.fixture
|
||||
def mock_insert_event(
|
||||
calendar_resource: google_discovery.Resource,
|
||||
) -> Mock:
|
||||
"""Fixture to create a mock to capture new events added to the API."""
|
||||
insert_mock = Mock()
|
||||
calendar_resource.return_value.events.return_value.insert = insert_mock
|
||||
return insert_mock
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> Callable[[..., dict[str, Any]], None]:
|
||||
"""Fixture for capturing event creation."""
|
||||
|
||||
def _expect_result(calendar_id: str = CALENDAR_ID) -> None:
|
||||
aioclient_mock.post(
|
||||
f"{API_BASE_URL}/calendars/{calendar_id}/events",
|
||||
)
|
||||
return
|
||||
|
||||
return _expect_result
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
@ -8,7 +8,7 @@ from typing import Any
|
||||
from unittest.mock import patch
|
||||
import urllib
|
||||
|
||||
import httplib2
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import pytest
|
||||
|
||||
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(
|
||||
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."""
|
||||
|
||||
now = dt_util.now()
|
||||
with patch("homeassistant.components.google.api.google_discovery.build") as mock:
|
||||
mock.return_value.calendarList.return_value.list.return_value.execute.return_value = {
|
||||
"items": [test_api_calendar]
|
||||
}
|
||||
mock.return_value.events.return_value.list.return_value.execute.return_value = {
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
mock_events_list(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
**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)
|
||||
assert state.name == TEST_ENTITY_NAME
|
||||
@ -332,10 +336,11 @@ async def test_update_error(
|
||||
|
||||
# Advance time to avoid throttling
|
||||
now += datetime.timedelta(minutes=30)
|
||||
with patch(
|
||||
"homeassistant.components.google.api.google_discovery.build",
|
||||
side_effect=httplib2.ServerNotFoundError("unit test"),
|
||||
), patch("homeassistant.util.utcnow", return_value=now):
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
mock_events_list({}, exc=ClientError())
|
||||
|
||||
with patch("homeassistant.util.utcnow", return_value=now):
|
||||
async_fire_time_changed(hass, now)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -346,10 +351,10 @@ async def test_update_error(
|
||||
|
||||
# Advance time beyond update/throttle point
|
||||
now += datetime.timedelta(minutes=30)
|
||||
with patch(
|
||||
"homeassistant.components.google.api.google_discovery.build"
|
||||
) as mock, patch("homeassistant.util.utcnow", return_value=now):
|
||||
mock.return_value.events.return_value.list.return_value.execute.return_value = {
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
mock_events_list(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
**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)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -371,8 +379,11 @@ async def test_update_error(
|
||||
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."""
|
||||
mock_events_list_items([])
|
||||
assert await component_setup()
|
||||
|
||||
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(
|
||||
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."""
|
||||
mock_events_list({})
|
||||
assert await component_setup()
|
||||
|
||||
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())
|
||||
assert response.status == HTTPStatus.OK
|
||||
@ -493,16 +511,14 @@ async def test_opaque_event(
|
||||
|
||||
async def test_scan_calendar_error(
|
||||
hass,
|
||||
calendar_resource,
|
||||
component_setup,
|
||||
test_api_calendar,
|
||||
mock_calendars_list,
|
||||
):
|
||||
"""Test that the calendar update handles a server error."""
|
||||
with patch(
|
||||
"homeassistant.components.google.api.google_discovery.build",
|
||||
side_effect=httplib2.ServerNotFoundError("unit test"),
|
||||
):
|
||||
assert await component_setup()
|
||||
|
||||
mock_calendars_list({}, exc=ClientError())
|
||||
assert await component_setup()
|
||||
|
||||
assert not hass.states.get(TEST_ENTITY)
|
||||
|
||||
|
@ -6,7 +6,7 @@ import datetime
|
||||
import http
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -134,10 +134,12 @@ async def test_calendar_yaml_error(
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_events_list: ApiResult,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup with yaml file not found."""
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
mock_events_list({})
|
||||
|
||||
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
|
||||
assert await component_setup()
|
||||
@ -182,6 +184,7 @@ async def test_track_new(
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_events_list: ApiResult,
|
||||
mock_calendars_yaml: None,
|
||||
expected_state: State,
|
||||
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."""
|
||||
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
mock_events_list({})
|
||||
assert await component_setup()
|
||||
|
||||
state = hass.states.get(TEST_API_ENTITY)
|
||||
@ -202,11 +206,13 @@ async def test_found_calendar_from_api(
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_events_list: ApiResult,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test finding a calendar from the API."""
|
||||
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
mock_events_list({})
|
||||
assert await component_setup()
|
||||
|
||||
state = hass.states.get(TEST_API_ENTITY)
|
||||
@ -240,6 +246,7 @@ async def test_calendar_config_track_new(
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
mock_events_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
calendars_config_track: bool,
|
||||
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."""
|
||||
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
mock_events_list({})
|
||||
assert await component_setup()
|
||||
|
||||
state = hass.states.get(TEST_YAML_ENTITY)
|
||||
assert_state(state, expected_state)
|
||||
|
||||
|
||||
async def test_add_event(
|
||||
async def test_add_event_missing_required_fields(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_insert_event: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call that adds an event."""
|
||||
"""Test service call that adds an event missing required fields."""
|
||||
|
||||
assert await component_setup()
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_EVENT,
|
||||
{
|
||||
"calendar_id": CALENDAR_ID,
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
},
|
||||
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": {},
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_EVENT,
|
||||
{
|
||||
"calendar_id": CALENDAR_ID,
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -308,17 +306,27 @@ async def test_add_event_date_in_x(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
mock_insert_event: Callable[[..., dict[str, Any]], None],
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_insert_event: Mock,
|
||||
date_fields: dict[str, Any],
|
||||
start_timedelta: datetime.timedelta,
|
||||
end_timedelta: datetime.timedelta,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test service call that adds an event with various time ranges."""
|
||||
|
||||
mock_calendars_list({})
|
||||
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(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_EVENT,
|
||||
@ -330,38 +338,36 @@ async def test_add_event_date_in_x(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_insert_event.assert_called()
|
||||
|
||||
now = datetime.datetime.now()
|
||||
start_date = now + start_timedelta
|
||||
end_date = now + end_timedelta
|
||||
|
||||
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()},
|
||||
},
|
||||
)
|
||||
assert len(aioclient_mock.mock_calls) == 2
|
||||
assert aioclient_mock.mock_calls[1][2] == {
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
"start": {"date": start_date.date().isoformat()},
|
||||
"end": {"date": end_date.date().isoformat()},
|
||||
}
|
||||
|
||||
|
||||
async def test_add_event_date(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
mock_insert_event: Mock,
|
||||
mock_insert_event: Callable[[str, dict[str, Any]], None],
|
||||
setup_config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test service call that sets a date range."""
|
||||
|
||||
mock_calendars_list({})
|
||||
assert await component_setup()
|
||||
|
||||
now = utcnow()
|
||||
today = now.date()
|
||||
end_date = today + datetime.timedelta(days=2)
|
||||
|
||||
mock_insert_event(
|
||||
calendar_id=CALENDAR_ID,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_EVENT,
|
||||
@ -374,35 +380,37 @@ async def test_add_event_date(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_insert_event.assert_called()
|
||||
|
||||
assert mock_insert_event.mock_calls[0] == call(
|
||||
calendarId=CALENDAR_ID,
|
||||
body={
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
"start": {"date": today.isoformat()},
|
||||
"end": {"date": end_date.isoformat()},
|
||||
},
|
||||
)
|
||||
assert len(aioclient_mock.mock_calls) == 2
|
||||
assert aioclient_mock.mock_calls[1][2] == {
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
"start": {"date": today.isoformat()},
|
||||
"end": {"date": end_date.isoformat()},
|
||||
}
|
||||
|
||||
|
||||
async def test_add_event_date_time(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
mock_insert_event: Callable[[str, dict[str, Any]], None],
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_insert_event: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test service call that adds an event with a date time range."""
|
||||
|
||||
mock_calendars_list({})
|
||||
assert await component_setup()
|
||||
|
||||
start_datetime = datetime.datetime.now()
|
||||
delta = datetime.timedelta(days=3, hours=3)
|
||||
end_datetime = start_datetime + delta
|
||||
|
||||
mock_insert_event(
|
||||
calendar_id=CALENDAR_ID,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_EVENT,
|
||||
@ -415,34 +423,32 @@ async def test_add_event_date_time(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_insert_event.assert_called()
|
||||
|
||||
assert mock_insert_event.mock_calls[0] == call(
|
||||
calendarId=CALENDAR_ID,
|
||||
body={
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
"start": {
|
||||
"dateTime": start_datetime.isoformat(timespec="seconds"),
|
||||
"timeZone": "America/Regina",
|
||||
},
|
||||
"end": {
|
||||
"dateTime": end_datetime.isoformat(timespec="seconds"),
|
||||
"timeZone": "America/Regina",
|
||||
},
|
||||
assert len(aioclient_mock.mock_calls) == 2
|
||||
assert aioclient_mock.mock_calls[1][2] == {
|
||||
"summary": "Summary",
|
||||
"description": "Description",
|
||||
"start": {
|
||||
"dateTime": start_datetime.isoformat(timespec="seconds"),
|
||||
"timeZone": "America/Regina",
|
||||
},
|
||||
)
|
||||
"end": {
|
||||
"dateTime": end_datetime.isoformat(timespec="seconds"),
|
||||
"timeZone": "America/Regina",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_scan_calendars(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_events_list: ApiResult,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test finding a calendar from the API."""
|
||||
|
||||
mock_calendars_list({"items": []})
|
||||
assert await component_setup()
|
||||
|
||||
calendar_1 = {
|
||||
@ -454,7 +460,9 @@ async def test_scan_calendars(
|
||||
"summary": "Calendar 2",
|
||||
}
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
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.async_block_till_done()
|
||||
|
||||
@ -464,7 +472,10 @@ async def test_scan_calendars(
|
||||
assert state.state == STATE_OFF
|
||||
assert not hass.states.get("calendar.calendar_2")
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
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.async_block_till_done()
|
||||
|
||||
|
Reference in New Issue
Block a user