Simplify google calendar API interactions (#67319)

* Simplify google calendar APIs and tests

* Simplify authentication logic at startup

* Improve readability of diffs

* Reduce diffs

* Simplify api datetime logic

* Remove duplicate test fixtures added in prior commit

* Remove duplicate event filter calls

* Fix event list argument names

* More improvements found from additional testing

* Remove unnecessary variables in create event call
This commit is contained in:
Allen Porter 2022-02-26 16:19:45 -08:00 committed by GitHub
parent 479aa13211
commit deda9e38e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 152 additions and 153 deletions

View File

@ -7,8 +7,6 @@ from enum import Enum
import logging
from typing import Any
from googleapiclient import discovery as google_discovery
import httplib2
from oauth2client.client import (
FlowExchangeError,
OAuth2DeviceCodeError,
@ -37,6 +35,8 @@ from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import convert
from .api import GoogleCalendarService
_LOGGER = logging.getLogger(__name__)
DOMAIN = "google"
@ -77,6 +77,7 @@ SERVICE_FOUND_CALENDARS = "found_calendar"
SERVICE_ADD_EVENT = "add_event"
DATA_CALENDARS = "calendars"
DATA_SERVICE = "service"
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
@ -251,13 +252,16 @@ def do_authentication(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google platform."""
hass.data[DOMAIN] = {DATA_CALENDARS: {}}
if not (conf := config.get(DOMAIN, {})):
# component is set up by tts platform
return True
storage = Storage(hass.config.path(TOKEN_FILE))
hass.data[DOMAIN] = {
DATA_CALENDARS: {},
DATA_SERVICE: GoogleCalendarService(hass, storage),
}
creds = storage.get()
if (
not creds
@ -271,34 +275,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def check_correct_scopes(
hass: HomeAssistant, token_file: str, config: ConfigType
) -> bool:
"""Check for the correct scopes in file."""
creds = Storage(token_file).get()
if not creds or not creds.scopes:
return False
target_scope = config[CONF_CALENDAR_ACCESS].scope
return target_scope in creds.scopes
class GoogleCalendarService:
"""Calendar service interface to Google."""
def __init__(self, token_file: str) -> None:
"""Init the Google Calendar service."""
self.token_file = token_file
def get(self) -> google_discovery.Resource:
"""Get the calendar service from the storage file token."""
credentials = Storage(self.token_file).get()
http = credentials.authorize(httplib2.Http())
service = google_discovery.build(
"calendar", "v3", http=http, cache_discovery=False
)
return service
def setup_services(
hass: HomeAssistant,
hass_config: ConfigType,
@ -328,9 +304,7 @@ def setup_services(
def _scan_for_calendars(call: ServiceCall) -> None:
"""Scan for new calendars."""
service = calendar_service.get()
cal_list = service.calendarList()
calendars = cal_list.list().execute()["items"]
calendars = calendar_service.list_calendars()
for calendar in calendars:
calendar["track"] = track_new_found_calendars
hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
@ -339,7 +313,6 @@ def setup_services(
def _add_event(call: ServiceCall) -> None:
"""Add a new event to calendar."""
service = calendar_service.get()
start = {}
end = {}
@ -374,14 +347,15 @@ def setup_services(
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
event = {
"summary": call.data[EVENT_SUMMARY],
"description": call.data[EVENT_DESCRIPTION],
"start": start,
"end": end,
}
service_data = {"calendarId": call.data[EVENT_CALENDAR_ID], "body": event}
event = service.events().insert(**service_data).execute()
calendar_service.create_event(
call.data[EVENT_CALENDAR_ID],
{
"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
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
@ -392,12 +366,11 @@ def setup_services(
def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None:
"""Run the setup after we have everything configured."""
_LOGGER.debug("Setting up integration")
# Load calendars the user has configured
calendars = load_config(hass.config.path(YAML_DEVICES))
hass.data[DOMAIN][DATA_CALENDARS] = calendars
calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE))
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
track_new_found_calendars = convert(
config.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW
)

View File

@ -0,0 +1,86 @@
"""Client library for talking to Google APIs."""
from __future__ import annotations
import datetime
import logging
from typing import Any
from googleapiclient import discovery as google_discovery
from oauth2client.file import Storage
from homeassistant.core import HomeAssistant
from homeassistant.util import dt
_LOGGER = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 100
def _api_time_format(time: datetime.datetime | None) -> str | None:
"""Convert a datetime to the api string format."""
return time.isoformat("T") if time else None
class GoogleCalendarService:
"""Calendar service interface to Google."""
def __init__(self, hass: HomeAssistant, storage: Storage) -> None:
"""Init the Google Calendar service."""
self._hass = hass
self._storage = storage
def _get_service(self) -> google_discovery.Resource:
"""Get the calendar service from the storage file token."""
return google_discovery.build(
"calendar", "v3", credentials=self._storage.get(), cache_discovery=False
)
def list_calendars(self) -> list[dict[str, Any]]:
"""Return the list of calendars the user has added to their list."""
cal_list = self._get_service().calendarList() # pylint: disable=no-member
return cal_list.list().execute()["items"]
def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]:
"""Create an event."""
events = self._get_service().events() # pylint: disable=no-member
return events.insert(calendarId=calendar_id, body=event).execute()
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."""
return await self._hass.async_add_executor_job(
self.list_events,
calendar_id,
start_time,
end_time,
search,
page_token,
)
def 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."""
events = self._get_service().events() # pylint: disable=no-member
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,
).execute()
return (result["items"], result.get("nextPageToken"))

View File

@ -6,7 +6,6 @@ from datetime import datetime, timedelta
import logging
from typing import Any
from googleapiclient import discovery as google_discovery
from httplib2 import ServerNotFoundError
from homeassistant.components.calendar import (
@ -20,17 +19,18 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt
from homeassistant.util import Throttle
from . import (
CONF_CAL_ID,
CONF_IGNORE_AVAILABILITY,
CONF_SEARCH,
CONF_TRACK,
DATA_SERVICE,
DEFAULT_CONF_OFFSET,
TOKEN_FILE,
GoogleCalendarService,
DOMAIN,
)
from .api import GoogleCalendarService
_LOGGER = logging.getLogger(__name__)
@ -61,7 +61,7 @@ def setup_platform(
if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
return
calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE))
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
entities = []
for data in disc_info[CONF_ENTITIES]:
if not data[CONF_TRACK]:
@ -150,23 +150,6 @@ class GoogleCalendarData:
self.ignore_availability = ignore_availability
self.event: dict[str, Any] | None = None
def _prepare_query(
self,
) -> tuple[google_discovery.Resource | None, dict[str, Any] | None]:
try:
service = self.calendar_service.get()
except ServerNotFoundError as err:
_LOGGER.error("Unable to connect to Google: %s", err)
return None, None
params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS)
params["calendarId"] = self.calendar_id
params["maxResults"] = 100 # Page size
if self.search:
params["q"] = self.search
return service, params
def _event_filter(self, event: dict[str, Any]) -> bool:
"""Return True if the event is visible."""
if self.ignore_availability:
@ -177,51 +160,36 @@ class GoogleCalendarData:
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[dict[str, Any]]:
"""Get all events in a specific time frame."""
service, params = await hass.async_add_executor_job(self._prepare_query)
if service is None or params is None:
return []
params["timeMin"] = start_date.isoformat("T")
params["timeMax"] = end_date.isoformat("T")
event_list: list[dict[str, Any]] = []
events = await hass.async_add_executor_job(service.events)
page_token: str | None = None
while True:
page_token = await self.async_get_events_page(
hass, events, params, page_token, event_list
)
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:
_LOGGER.error("Unable to connect to Google: %s", err)
return []
event_list.extend(filter(self._event_filter, items))
if not page_token:
break
return event_list
async def async_get_events_page(
self,
hass: HomeAssistant,
events: google_discovery.Resource,
params: dict[str, Any],
page_token: str | None,
event_list: list[dict[str, Any]],
) -> str | None:
"""Get a page of events in a specific time frame."""
params["pageToken"] = page_token
result = await hass.async_add_executor_job(events.list(**params).execute)
items = result.get("items", [])
visible_items = filter(self._event_filter, items)
event_list.extend(visible_items)
return result.get("nextPageToken")
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self) -> None:
"""Get the latest data."""
service, params = self._prepare_query()
if service is None or params is None:
try:
items, _ = self.calendar_service.list_events(
self.calendar_id, search=self.search
)
except ServerNotFoundError as err:
_LOGGER.error("Unable to connect to Google: %s", err)
return
params["timeMin"] = dt.now().isoformat("T")
events = service.events()
result = events.list(**params).execute()
items = result.get("items", [])
valid_events = filter(self._event_filter, items)
self.event = next(valid_events, None)

View File

@ -6,10 +6,10 @@ import datetime
from typing import Any, Generator, TypeVar
from unittest.mock import Mock, patch
from googleapiclient import discovery as google_discovery
from oauth2client.client import Credentials, OAuth2Credentials
import pytest
from homeassistant.components.google import GoogleCalendarService
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
@ -108,14 +108,21 @@ def mock_next_event():
yield google_cal_data
@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(
google_service: GoogleCalendarService,
calendar_resource: google_discovery.Resource,
) -> Callable[[dict[str, Any]], None]:
"""Fixture to construct a fake event list API response."""
def _put_result(response: dict[str, Any]) -> None:
google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = (
calendar_resource.return_value.events.return_value.list.return_value.execute.return_value = (
response
)
return
@ -125,12 +132,12 @@ def mock_events_list(
@pytest.fixture
def mock_calendars_list(
google_service: GoogleCalendarService,
calendar_resource: google_discovery.Resource,
) -> ApiResult:
"""Fixture to construct a fake calendar list API response."""
def _put_result(response: dict[str, Any]) -> None:
google_service.return_value.get.return_value.calendarList.return_value.list.return_value.execute.return_value = (
calendar_resource.return_value.calendarList.return_value.list.return_value.execute.return_value = (
response
)
return
@ -140,11 +147,9 @@ def mock_calendars_list(
@pytest.fixture
def mock_insert_event(
google_service: GoogleCalendarService,
calendar_resource: google_discovery.Resource,
) -> Mock:
"""Fixture to create a mock to capture new events added to the API."""
insert_mock = Mock()
google_service.return_value.get.return_value.events.return_value.insert = (
insert_mock
)
calendar_resource.return_value.events.return_value.insert = insert_mock
return insert_mock

View File

@ -110,17 +110,7 @@ def set_time_zone():
dt_util.set_default_time_zone(dt_util.get_time_zone("UTC"))
@pytest.fixture(name="google_service")
def mock_google_service():
"""Mock google service."""
patch_google_service = patch(
"homeassistant.components.google.calendar.GoogleCalendarService"
)
with patch_google_service as mock_service:
yield mock_service
async def test_all_day_event(hass, mock_next_event):
async def test_all_day_event(hass, mock_next_event, mock_token_read):
"""Test that we can create an event trigger on device."""
week_from_today = dt_util.dt.date.today() + dt_util.dt.timedelta(days=7)
end_event = week_from_today + dt_util.dt.timedelta(days=1)
@ -302,9 +292,9 @@ async def test_all_day_offset_event(hass, mock_next_event):
}
async def test_update_error(hass, google_service):
async def test_update_error(hass, calendar_resource):
"""Test that the calendar handles a server error."""
google_service.return_value.get = Mock(
calendar_resource.return_value.get = Mock(
side_effect=httplib2.ServerNotFoundError("unit test")
)
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
@ -315,7 +305,7 @@ async def test_update_error(hass, google_service):
assert state.state == "off"
async def test_calendars_api(hass, hass_client, google_service):
async def test_calendars_api(hass, hass_client):
"""Test the Rest API returns the calendar."""
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
await hass.async_block_till_done()
@ -332,11 +322,9 @@ async def test_calendars_api(hass, hass_client, google_service):
]
async def test_http_event_api_failure(hass, hass_client, google_service):
async def test_http_event_api_failure(hass, hass_client, calendar_resource):
"""Test the Rest API response during a calendar failure."""
google_service.return_value.get = Mock(
side_effect=httplib2.ServerNotFoundError("unit test")
)
calendar_resource.side_effect = httplib2.ServerNotFoundError("unit test")
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
await hass.async_block_till_done()
@ -352,7 +340,7 @@ async def test_http_event_api_failure(hass, hass_client, google_service):
assert events == []
async def test_http_api_event(hass, hass_client, google_service, mock_events_list):
async def test_http_api_event(hass, hass_client, mock_events_list):
"""Test querying the API and fetching events from the server."""
now = dt_util.now()
@ -392,7 +380,7 @@ def create_ignore_avail_calendar() -> dict[str, Any]:
@pytest.mark.parametrize("test_calendar", [create_ignore_avail_calendar()])
async def test_opaque_event(hass, hass_client, google_service, mock_events_list):
async def test_opaque_event(hass, hass_client, mock_events_list):
"""Test querying the API and fetching events from the server."""
now = dt_util.now()
@ -426,7 +414,7 @@ async def test_opaque_event(hass, hass_client, google_service, mock_events_list)
@pytest.mark.parametrize("test_calendar", [create_ignore_avail_calendar()])
async def test_transparent_event(hass, hass_client, google_service, mock_events_list):
async def test_transparent_event(hass, hass_client, mock_events_list):
"""Test querying the API and fetching events from the server."""
now = dt_util.now()

View File

@ -12,11 +12,7 @@ from oauth2client.client import (
import pytest
import yaml
from homeassistant.components.google import (
DOMAIN,
SERVICE_ADD_EVENT,
GoogleCalendarService,
)
from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -118,15 +114,6 @@ async def component_setup(
return _setup_func
@pytest.fixture
async def google_service() -> YieldFixture[GoogleCalendarService]:
"""Fixture to capture service calls."""
with patch("homeassistant.components.google.GoogleCalendarService") as mock, patch(
"homeassistant.components.google.calendar.GoogleCalendarService", mock
):
yield mock
async def fire_alarm(hass, point_in_time):
"""Fire an alarm and wait for callbacks to run."""
with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
@ -150,7 +137,6 @@ async def test_setup_config_empty(
async def test_init_success(
hass: HomeAssistant,
google_service: GoogleCalendarService,
mock_code_flow: Mock,
mock_exchange: Mock,
mock_notification: Mock,
@ -243,7 +229,6 @@ async def test_existing_token(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_yaml: None,
mock_notification: Mock,
) -> None:
@ -266,7 +251,6 @@ async def test_existing_token_missing_scope(
token_scopes: list[str],
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_yaml: None,
mock_notification: Mock,
mock_code_flow: Mock,
@ -295,7 +279,6 @@ async def test_calendar_yaml_missing_required_fields(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
@ -313,7 +296,6 @@ async def test_invalid_calendar_yaml(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
@ -332,7 +314,6 @@ async def test_found_calendar_from_api(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
) -> None:
@ -354,7 +335,6 @@ async def test_add_event(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
mock_insert_event: Mock,
@ -416,7 +396,6 @@ async def test_add_event_date_ranges(
mock_token_read: None,
calendars_config: list[dict[str, Any]],
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
mock_insert_event: Mock,