diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0769422366e..08f6bf247f4 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -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 ) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py new file mode 100644 index 00000000000..8652f8b15ed --- /dev/null +++ b/homeassistant/components/google/api.py @@ -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")) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d9fcbbd593d..e068d817429 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -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) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 3c354b36226..b78f97e8209 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -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 diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 6f91fed7b24..80cf9c3d67d 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -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() diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 8fbcb97bfe2..ae803e9fd3b 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -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,