Update google calendar integration with a config flow (#68010)

* Convert google calendar to config flow and async

* Call correct exchange method

* Fix async method and reduce unnecessary diffs

* Wording improvements

* Reduce unnecessary diffs

* Run load/update config from executor

* Update homeassistant/components/google/calendar.py

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

* Remove unnecessary updating of unexpected multiple config entries.

* Remove unnecessary unique_id checks

* Improve readability with comments about device code expiration

* Update homeassistant/components/google/calendar.py

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

* Update homeassistant/components/google/calendar.py

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

* Update homeassistant/components/google/api.py

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

* Add comment for when code is none on timeout

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-03-14 23:51:02 -07:00 committed by GitHub
parent 4988c4683c
commit 7876ffe9e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 989 additions and 443 deletions

View File

@ -1,23 +1,20 @@
"""Support for Google - Calendar Event Devices.""" """Support for Google - Calendar Event Devices."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from enum import Enum
import logging import logging
from typing import Any from typing import Any
from oauth2client.client import ( from httplib2.error import ServerNotFoundError
FlowExchangeError,
OAuth2DeviceCodeError,
OAuth2WebServerFlow,
)
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
import yaml import yaml
from homeassistant.components import persistent_notification from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_CLIENT_ID, CONF_CLIENT_ID,
CONF_CLIENT_SECRET, CONF_CLIENT_SECRET,
@ -25,20 +22,28 @@ from homeassistant.const import (
CONF_ENTITIES, CONF_ENTITIES,
CONF_NAME, CONF_NAME,
CONF_OFFSET, CONF_OFFSET,
Platform,
) )
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import discovery from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import config_entry_oauth2_flow
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.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .api import GoogleCalendarService from . import config_flow
from .api import DeviceAuth, GoogleCalendarService
from .const import (
CONF_CALENDAR_ACCESS,
DATA_CONFIG,
DATA_SERVICE,
DISCOVER_CALENDAR,
DOMAIN,
FeatureAccess,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "google"
ENTITY_ID_FORMAT = DOMAIN + ".{}" ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_TRACK_NEW = "track_new_calendar" CONF_TRACK_NEW = "track_new_calendar"
@ -48,7 +53,6 @@ CONF_TRACK = "track"
CONF_SEARCH = "search" CONF_SEARCH = "search"
CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_IGNORE_AVAILABILITY = "ignore_availability"
CONF_MAX_RESULTS = "max_results" CONF_MAX_RESULTS = "max_results"
CONF_CALENDAR_ACCESS = "calendar_access"
DEFAULT_CONF_OFFSET = "!!" DEFAULT_CONF_OFFSET = "!!"
@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars"
SERVICE_FOUND_CALENDARS = "found_calendar" SERVICE_FOUND_CALENDARS = "found_calendar"
SERVICE_ADD_EVENT = "add_event" SERVICE_ADD_EVENT = "add_event"
DATA_SERVICE = "service"
YAML_DEVICES = f"{DOMAIN}_calendars.yaml" YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
TOKEN_FILE = f".{DOMAIN}.token" TOKEN_FILE = f".{DOMAIN}.token"
PLATFORMS = ["calendar"]
class FeatureAccess(Enum):
"""Class to represent different access scopes."""
read_only = "https://www.googleapis.com/auth/calendar.readonly"
read_write = "https://www.googleapis.com/auth/calendar"
def __init__(self, scope: str) -> None:
"""Init instance."""
self._scope = scope
@property
def scope(self) -> str:
"""Google calendar scope for the feature."""
return self._scope
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
) )
def do_authentication( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass: HomeAssistant, """Set up the Google component."""
hass_config: ConfigType, conf = config.get(DOMAIN, {})
config: ConfigType, hass.data[DOMAIN] = {DATA_CONFIG: conf}
storage: Storage, config_flow.OAuth2FlowHandler.async_register_implementation(
) -> bool:
"""Notify user of actions and authenticate.
Notify user of user_code and verification_url then poll
until we have an access token.
"""
oauth = OAuth2WebServerFlow(
client_id=config[CONF_CLIENT_ID],
client_secret=config[CONF_CLIENT_SECRET],
scope=config[CONF_CALENDAR_ACCESS].scope,
redirect_uri="Home-Assistant.io",
)
try:
dev_flow = oauth.step1_get_device_and_user_codes()
except OAuth2DeviceCodeError as err:
persistent_notification.create(
hass,
f"Error: {err}<br />You will need to restart hass after fixing." "",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
return False
persistent_notification.create(
hass, hass,
( DeviceAuth(
f"In order to authorize Home-Assistant to view your calendars "
f'you must visit: <a href="{dev_flow.verification_url}" target="_blank">{dev_flow.verification_url}</a> and enter '
f"code: {dev_flow.user_code}"
),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
listener: CALLBACK_TYPE | None = None
def step2_exchange(now: datetime) -> None:
"""Keep trying to validate the user_code until it expires."""
_LOGGER.debug("Attempting to validate user code")
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
# object without tzinfo. For the comparison below to work, it needs one.
user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc)
if now >= user_code_expiry:
persistent_notification.create(
hass,
"Authentication code expired, please restart "
"Home-Assistant and try again",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
assert listener
listener()
return
try:
credentials = oauth.step2_exchange(device_flow_info=dev_flow)
except FlowExchangeError:
# not ready yet, call again
return
storage.put(credentials)
do_setup(hass, hass_config, config)
assert listener
listener()
persistent_notification.create(
hass, hass,
( conf[CONF_CLIENT_ID],
f"We are all setup now. Check {YAML_DEVICES} for calendars that have " conf[CONF_CLIENT_SECRET],
f"been found" ),
),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
listener = track_utc_time_change(
hass, step2_exchange, second=range(1, 60, dev_flow.interval)
) )
return True # Import credentials from the old token file into the new way as
# a ConfigEntry managed by home assistant.
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google platform."""
if not (conf := config.get(DOMAIN, {})):
# component is set up by tts platform
return True
storage = Storage(hass.config.path(TOKEN_FILE)) storage = Storage(hass.config.path(TOKEN_FILE))
hass.data[DOMAIN] = { creds = await hass.async_add_executor_job(storage.get)
DATA_SERVICE: GoogleCalendarService(hass, storage), if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes:
} _LOGGER.debug("Importing configuration entry with credentials")
creds = storage.get() hass.async_create_task(
if ( hass.config_entries.flow.async_init(
not creds DOMAIN,
or not creds.scopes context={"source": config_entries.SOURCE_IMPORT},
or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes data={
): "creds": creds,
do_authentication(hass, config, conf, storage) },
else: )
do_setup(hass, config, conf) )
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
assert isinstance(implementation, DeviceAuth)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope
if required_scope not in session.token.get("scope", []):
raise ConfigEntryAuthFailed(
"Required scopes are not available, reauth required"
)
calendar_service = GoogleCalendarService(hass, session)
hass.data[DOMAIN][DATA_SERVICE] = calendar_service
await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True return True
def setup_services( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_setup_services(
hass: HomeAssistant, hass: HomeAssistant,
hass_config: ConfigType,
config: ConfigType, config: ConfigType,
calendar_service: GoogleCalendarService, calendar_service: GoogleCalendarService,
) -> None: ) -> None:
"""Set up the service listeners.""" """Set up the service listeners."""
created_calendars = set() created_calendars = set()
calendars = load_config(hass.config.path(YAML_DEVICES)) calendars = await hass.async_add_executor_job(
load_config, hass.config.path(YAML_DEVICES)
)
def _found_calendar(call: ServiceCall) -> None: async def _found_calendar(call: ServiceCall) -> None:
"""Check if we know about a calendar and generate PLATFORM_DISCOVER."""
calendar = get_calendar_info(hass, call.data) calendar = get_calendar_info(hass, call.data)
calendar_id = calendar[CONF_CAL_ID] calendar_id = calendar[CONF_CAL_ID]
@ -294,31 +230,33 @@ def setup_services(
# Populate the yaml file with all discovered calendars # Populate the yaml file with all discovered calendars
if calendar_id not in calendars: if calendar_id not in calendars:
calendars[calendar_id] = calendar calendars[calendar_id] = calendar
update_config(hass.config.path(YAML_DEVICES), calendar) await hass.async_add_executor_job(
update_config, hass.config.path(YAML_DEVICES), calendar
)
else: else:
# Prefer entity/name information from yaml, overriding api # Prefer entity/name information from yaml, overriding api
calendar = calendars[calendar_id] calendar = calendars[calendar_id]
async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar)
discovery.load_platform( hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
hass,
Platform.CALENDAR,
DOMAIN,
calendar,
hass_config,
)
hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) async def _scan_for_calendars(call: ServiceCall) -> None:
def _scan_for_calendars(call: ServiceCall) -> None:
"""Scan for new calendars.""" """Scan for new calendars."""
calendars = calendar_service.list_calendars() try:
calendars = await calendar_service.async_list_calendars()
except ServerNotFoundError as err:
raise HomeAssistantError(str(err)) from err
tasks = []
for calendar in calendars: for calendar in calendars:
calendar[CONF_TRACK] = config[CONF_TRACK_NEW] calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) tasks.append(
hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
)
await asyncio.gather(*tasks)
hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
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 = {}
end = {} end = {}
@ -354,7 +292,7 @@ def setup_services(
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)} start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)} end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
calendar_service.create_event( await calendar_service.async_create_event(
call.data[EVENT_CALENDAR_ID], call.data[EVENT_CALENDAR_ID],
{ {
"summary": call.data[EVENT_SUMMARY], "summary": call.data[EVENT_SUMMARY],
@ -366,20 +304,11 @@ def setup_services(
# Only expose the add event service if we have the correct permissions # Only expose the add event service if we have the correct permissions
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write: if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
hass.services.register( hass.services.async_register(
DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
) )
def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None:
"""Run the setup after we have everything configured."""
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
setup_services(hass, hass_config, config, calendar_service)
# Fetch calendars from the API
hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
def get_calendar_info( def get_calendar_info(
hass: HomeAssistant, calendar: Mapping[str, Any] hass: HomeAssistant, calendar: Mapping[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:

View File

@ -2,20 +2,166 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
import datetime import datetime
import logging import logging
from typing import Any from typing import Any
from googleapiclient import discovery as google_discovery from googleapiclient import discovery as google_discovery
from oauth2client.file import Storage import oauth2client
from oauth2client.client import (
Credentials,
DeviceFlowInfo,
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
OAuth2WebServerFlow,
)
from homeassistant.core import HomeAssistant from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt from homeassistant.util import dt
from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 100 EVENT_PAGE_SIZE = 100
EXCHANGE_TIMEOUT_SECONDS = 60
class OAuthError(Exception):
"""OAuth related error."""
class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""OAuth implementation for Device Auth."""
def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None:
"""Initialize InstalledAppAuth."""
super().__init__(
hass,
DEVICE_AUTH_IMPL,
client_id,
client_secret,
oauth2client.GOOGLE_AUTH_URI,
oauth2client.GOOGLE_TOKEN_URI,
)
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve a Google API Credentials object to Home Assistant token."""
creds: Credentials = external_data["creds"]
return {
"access_token": creds.access_token,
"refresh_token": creds.refresh_token,
"scope": " ".join(creds.scopes),
"token_type": "Bearer",
"expires_in": creds.token_expiry.timestamp(),
}
class DeviceFlow:
"""OAuth2 device flow for exchanging a code for an access token."""
def __init__(
self,
hass: HomeAssistant,
oauth_flow: OAuth2WebServerFlow,
device_flow_info: DeviceFlowInfo,
) -> None:
"""Initialize DeviceFlow."""
self._hass = hass
self._oauth_flow = oauth_flow
self._device_flow_info: DeviceFlowInfo = device_flow_info
self._exchange_task_unsub: CALLBACK_TYPE | None = None
@property
def verification_url(self) -> str:
"""Return the verification url that the user should visit to enter the code."""
return self._device_flow_info.verification_url
@property
def user_code(self) -> str:
"""Return the code that the user should enter at the verification url."""
return self._device_flow_info.user_code
async def start_exchange_task(
self, finished_cb: Callable[[Credentials | None], Awaitable[None]]
) -> None:
"""Start the device auth exchange flow polling.
The callback is invoked with the valid credentials or with None on timeout.
"""
_LOGGER.debug("Starting exchange flow")
assert not self._exchange_task_unsub
max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS)
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
# object without tzinfo. For the comparison below to work, it needs one.
user_code_expiry = self._device_flow_info.user_code_expiry.replace(
tzinfo=datetime.timezone.utc
)
expiration_time = min(user_code_expiry, max_timeout)
def _exchange() -> Credentials:
return self._oauth_flow.step2_exchange(
device_flow_info=self._device_flow_info
)
async def _poll_attempt(now: datetime.datetime) -> None:
assert self._exchange_task_unsub
_LOGGER.debug("Attempting OAuth code exchange")
# Note: The callback is invoked with None when the device code has expired
creds: Credentials | None = None
if now < expiration_time:
try:
creds = await self._hass.async_add_executor_job(_exchange)
except FlowExchangeError:
_LOGGER.debug("Token not yet ready; trying again later")
return
self._exchange_task_unsub()
self._exchange_task_unsub = None
await finished_cb(creds)
self._exchange_task_unsub = async_track_time_interval(
self._hass,
_poll_attempt,
datetime.timedelta(seconds=self._device_flow_info.interval),
)
async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
"""Create a new Device flow."""
conf = hass.data[DOMAIN][DATA_CONFIG]
oauth_flow = OAuth2WebServerFlow(
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
scope=conf[CONF_CALENDAR_ACCESS].scope,
redirect_uri="",
)
try:
device_flow_info = await hass.async_add_executor_job(
oauth_flow.step1_get_device_and_user_codes
)
except OAuth2DeviceCodeError as err:
raise OAuthError(str(err)) from err
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=token["expires_at"],
token_uri=oauth2client.GOOGLE_TOKEN_URI,
scopes=[conf[CONF_CALENDAR_ACCESS].scope],
user_agent=None,
)
def _api_time_format(time: datetime.datetime | None) -> str | None: def _api_time_format(time: datetime.datetime | None) -> str | None:
@ -26,26 +172,44 @@ def _api_time_format(time: datetime.datetime | None) -> str | None:
class GoogleCalendarService: class GoogleCalendarService:
"""Calendar service interface to Google.""" """Calendar service interface to Google."""
def __init__(self, hass: HomeAssistant, storage: Storage) -> None: def __init__(
self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session
) -> None:
"""Init the Google Calendar service.""" """Init the Google Calendar service."""
self._hass = hass self._hass = hass
self._storage = storage self._session = session
def _get_service(self) -> google_discovery.Resource: async def _async_get_service(self) -> google_discovery.Resource:
"""Get the calendar service from the storage file token.""" """Get the calendar service with valid credetnails."""
await self._session.async_ensure_token_valid()
creds = _async_google_creds(self._hass, self._session.token)
return google_discovery.build( return google_discovery.build(
"calendar", "v3", credentials=self._storage.get(), cache_discovery=False "calendar", "v3", credentials=creds, cache_discovery=False
) )
def list_calendars(self) -> list[dict[str, Any]]: async def async_list_calendars(
self,
) -> list[dict[str, Any]]:
"""Return the list of calendars the user has added to their list.""" """Return the list of calendars the user has added to their list."""
cal_list = self._get_service().calendarList() # pylint: disable=no-member service = await self._async_get_service()
return cal_list.list().execute()["items"]
def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]: def _list_calendars() -> list[dict[str, Any]]:
"""Create an event.""" cal_list = service.calendarList() # pylint: disable=no-member
events = self._get_service().events() # pylint: disable=no-member return cal_list.list().execute()["items"]
return events.insert(calendarId=calendar_id, body=event).execute()
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() # pylint: disable=no-member
return events.insert(calendarId=calendar_id, body=event).execute()
return await self._hass.async_add_executor_job(_create_event)
async def async_list_events( async def async_list_events(
self, self,
@ -56,33 +220,20 @@ class GoogleCalendarService:
page_token: str | None = None, page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
"""Return the list of events.""" """Return the list of events."""
return await self._hass.async_add_executor_job( service = await self._async_get_service()
self.list_events,
calendar_id,
start_time,
end_time,
search,
page_token,
)
def list_events( def _list_events() -> tuple[list[dict[str, Any]], str | None]:
self, events = service.events() # pylint: disable=no-member
calendar_id: str, result = events.list(
start_time: datetime.datetime | None = None, calendarId=calendar_id,
end_time: datetime.datetime | None = None, timeMin=_api_time_format(start_time if start_time else dt.now()),
search: str | None = None, timeMax=_api_time_format(end_time),
page_token: str | None = None, q=search,
) -> tuple[list[dict[str, Any]], str | None]: maxResults=EVENT_PAGE_SIZE,
"""Return the list of events.""" pageToken=page_token,
events = self._get_service().events() # pylint: disable=no-member singleEvents=True, # Flattens recurring events
result = events.list( orderBy="startTime",
calendarId=calendar_id, ).execute()
timeMin=_api_time_format(start_time if start_time else dt.now()), return (result["items"], result.get("nextPageToken"))
timeMax=_api_time_format(end_time),
q=search, return await self._hass.async_add_executor_job(_list_events)
maxResults=EVENT_PAGE_SIZE,
pageToken=page_token,
singleEvents=True, # Flattens recurring events
orderBy="startTime",
).execute()
return (result["items"], result.get("nextPageToken"))

View File

@ -14,11 +14,13 @@ from homeassistant.components.calendar import (
calculate_offset, calculate_offset,
is_offset_reached, is_offset_reached,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
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 import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle from homeassistant.util import Throttle
from . import ( from . import (
@ -29,8 +31,10 @@ from . import (
DATA_SERVICE, DATA_SERVICE,
DEFAULT_CONF_OFFSET, DEFAULT_CONF_OFFSET,
DOMAIN, DOMAIN,
SERVICE_SCAN_CALENDARS,
) )
from .api import GoogleCalendarService from .api import GoogleCalendarService
from .const import DISCOVER_CALENDAR
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -48,19 +52,39 @@ TRANSPARENCY = "transparency"
OPAQUE = "opaque" OPAQUE = "opaque"
def setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
config: ConfigType,
add_entities: AddEntitiesCallback,
disc_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the calendar platform for event devices.""" """Set up the google calendar platform."""
if disc_info is None:
return
if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]): @callback
return def async_discover(discovery_info: dict[str, Any]) -> None:
_async_setup_entities(
hass,
entry,
async_add_entities,
discovery_info,
)
entry.async_on_unload(
async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover)
)
# Look for any new calendars
try:
await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True)
except HomeAssistantError as err:
# This can happen if there's a connection error during setup.
raise PlatformNotReady(str(err)) from err
@callback
def _async_setup_entities(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
disc_info: dict[str, Any],
) -> None:
calendar_service = hass.data[DOMAIN][DATA_SERVICE] calendar_service = hass.data[DOMAIN][DATA_SERVICE]
entities = [] entities = []
for data in disc_info[CONF_ENTITIES]: for data in disc_info[CONF_ENTITIES]:
@ -74,7 +98,7 @@ def setup_platform(
) )
entities.append(entity) entities.append(entity)
add_entities(entities, True) async_add_entities(entities, True)
class GoogleCalendarEventDevice(CalendarEventDevice): class GoogleCalendarEventDevice(CalendarEventDevice):
@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
return event_list return event_list
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self) -> None: async def async_update(self) -> None:
"""Get the latest data.""" """Get the latest data."""
try: try:
items, _ = self._calendar_service.list_events( items, _ = await self._calendar_service.async_list_events(
self._calendar_id, search=self._search self._calendar_id, search=self._search
) )
except ServerNotFoundError as err: except ServerNotFoundError as err:

View File

@ -0,0 +1,128 @@
"""Config flow for Google integration."""
from __future__ import annotations
import logging
from typing import Any
from oauth2client.client import Credentials
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .api import DeviceFlow, OAuthError, async_create_device_flow
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google Calendars OAuth2 authentication."""
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Set up instance."""
super().__init__()
self._reauth = False
self._device_flow: DeviceFlow | None = None
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_step_import(self, info: dict[str, Any]) -> FlowResult:
"""Import existing auth from Nest."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
implementations = await config_entry_oauth2_flow.async_get_implementations(
self.hass, self.DOMAIN
)
assert len(implementations) == 1
self.flow_impl = list(implementations.values())[0]
self.external_data = info
return await super().async_step_creation(info)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle external yaml configuration."""
if not self._reauth and self._async_current_entries():
return self.async_abort(reason="already_configured")
return await super().async_step_user(user_input)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Create an entry for auth."""
# The default behavior from the parent class is to redirect the
# user with an external step. When using the device flow, we instead
# prompt the user to visit a URL and enter a code. The device flow
# background task will poll the exchange endpoint to get valid
# creds or until a timeout is complete.
if user_input is not None:
return self.async_show_progress_done(next_step_id="creation")
if not self._device_flow:
_LOGGER.debug("Creating DeviceAuth flow")
try:
device_flow = await async_create_device_flow(self.hass)
except OAuthError as err:
_LOGGER.error("Error initializing device flow: %s", str(err))
return self.async_abort(reason="oauth_error")
self._device_flow = device_flow
async def _exchange_finished(creds: Credentials | None) -> None:
self.external_data = {"creds": creds} # is None on timeout/expiration
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(
flow_id=self.flow_id, user_input={}
)
)
await device_flow.start_exchange_task(_exchange_finished)
return self.async_show_progress(
step_id="auth",
description_placeholders={
"url": self._device_flow.verification_url,
"user_code": self._device_flow.user_code,
},
progress_action="exchange",
)
async def async_step_creation(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle external yaml configuration."""
if self.external_data.get("creds") is None:
return self.async_abort(reason="code_expired")
return await super().async_step_creation(user_input)
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""
existing_entries = self._async_current_entries()
if existing_entries:
assert len(existing_entries) == 1
entry = existing_entries[0]
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self.flow_impl.name, data=data)
async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth = True
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@ -0,0 +1,30 @@
"""Constants for google integration."""
from __future__ import annotations
from enum import Enum
DOMAIN = "google"
DEVICE_AUTH_IMPL = "device_auth"
CONF_CALENDAR_ACCESS = "calendar_access"
DATA_CALENDARS = "calendars"
DATA_SERVICE = "service"
DATA_CONFIG = "config"
DISCOVER_CALENDAR = "google_discover_calendar"
class FeatureAccess(Enum):
"""Class to represent different access scopes."""
read_only = "https://www.googleapis.com/auth/calendar.readonly"
read_write = "https://www.googleapis.com/auth/calendar"
def __init__(self, scope: str) -> None:
"""Init instance."""
self._scope = scope
@property
def scope(self) -> str:
"""Google calendar scope for the feature."""
return self._scope

View File

@ -1,6 +1,8 @@
{ {
"domain": "google", "domain": "google",
"name": "Google Calendars", "name": "Google Calendars",
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/calendar.google/", "documentation": "https://www.home-assistant.io/integrations/calendar.google/",
"requirements": [ "requirements": [
"google-api-python-client==2.38.0", "google-api-python-client==2.38.0",

View File

@ -0,0 +1,31 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Nest integration needs to re-authenticate your account"
},
"auth": {
"title": "Link Google Account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"code_expired": "Authentication code expired or credential setup is invalid, please try again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"progress": {
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
}
}
}

View File

@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"already_in_progress": "Configuration flow is already in progress",
"code_expired": "Authentication code expired, please try again.",
"invalid_access_token": "Invalid access token",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth_error": "Received invalid token data.",
"reauth_successful": "Re-authentication was successful"
},
"create_entry": {
"default": "Successfully authenticated"
},
"progress": {
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
},
"step": {
"auth": {
"title": "Link Google Account"
},
"pick_implementation": {
"title": "Pick Authentication Method"
},
"reauth_confirm": {
"description": "The Nest integration needs to re-authenticate your account",
"title": "Reauthenticate Integration"
}
}
}
}

View File

@ -125,6 +125,7 @@ FLOWS = [
"goalzero", "goalzero",
"gogogate2", "gogogate2",
"goodwe", "goodwe",
"google",
"google_travel_time", "google_travel_time",
"gpslogger", "gpslogger",
"gree", "gree",

View File

@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from tests.common import MockConfigEntry
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
ApiResult = Callable[[dict[str, Any]], None] ApiResult = Callable[[dict[str, Any]], None]
@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]:
yield storage yield storage
@pytest.fixture
async def config_entry(token_scopes: list[str]) -> MockConfigEntry:
"""Fixture to create a config entry for the integration."""
token_expiry = utcnow() + datetime.timedelta(days=7)
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": " ".join(token_scopes),
"token_type": "Bearer",
"expires_at": token_expiry.timestamp(),
},
},
)
@pytest.fixture @pytest.fixture
async def mock_token_read( async def mock_token_read(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -53,10 +53,15 @@ TEST_EVENT = {
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_test_setup( def mock_test_setup(
mock_calendars_yaml, test_api_calendar, mock_calendars_list, mock_token_read hass,
mock_calendars_yaml,
test_api_calendar,
mock_calendars_list,
config_entry,
): ):
"""Fixture that pulls in the default fixtures for tests in this file.""" """Fixture that pulls in the default fixtures for tests in this file."""
mock_calendars_list({"items": [test_api_calendar]}) mock_calendars_list({"items": [test_api_calendar]})
config_entry.add_to_hass(hass)
return return
@ -300,12 +305,11 @@ async def test_update_error(
assert state.name == TEST_ENTITY_NAME assert state.name == TEST_ENTITY_NAME
assert state.state == "on" assert state.state == "on"
# Advance time to avoid throttling # Advance time beyond update/throttle point
now += datetime.timedelta(minutes=30) now += datetime.timedelta(minutes=30)
with patch( with patch(
"homeassistant.components.google.api.google_discovery.build" "homeassistant.components.google.api.google_discovery.build"
) as mock, patch("homeassistant.util.utcnow", return_value=now): ) as mock, patch("homeassistant.util.utcnow", return_value=now):
mock.return_value.events.return_value.list.return_value.execute.return_value = { mock.return_value.events.return_value.list.return_value.execute.return_value = {
"items": [ "items": [
{ {
@ -417,3 +421,19 @@ async def test_opaque_event(
assert response.status == HTTPStatus.OK assert response.status == HTTPStatus.OK
events = await response.json() events = await response.json()
assert (len(events) > 0) == expect_visible_event assert (len(events) > 0) == expect_visible_event
async def test_scan_calendar_error(
hass,
calendar_resource,
component_setup,
test_api_calendar,
):
"""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()
assert not hass.states.get(TEST_ENTITY)

View File

@ -0,0 +1,350 @@
"""Test the google config flow."""
import datetime
from unittest.mock import Mock, patch
from oauth2client.client import (
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
)
import pytest
from homeassistant import config_entries
from homeassistant.components.google.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from .conftest import ComponentSetup, YieldFixture
from tests.common import MockConfigEntry, async_fire_time_changed
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
@pytest.fixture(autouse=True)
async def request_setup(current_request_with_host) -> None:
"""Request setup."""
return
@pytest.fixture
async def code_expiration_delta() -> datetime.timedelta:
"""Fixture for code expiration time, defaulting to the future."""
return datetime.timedelta(minutes=3)
@pytest.fixture
async def mock_code_flow(
code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
"""Fixture for initiating OAuth flow."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
) as mock_flow:
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
yield mock_flow
@pytest.fixture
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
"""Fixture for mocking out the exchange for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
) as 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):
async_fire_time_changed(hass, point_in_time)
await hass.async_block_till_done()
async def test_full_flow(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "create_entry"
assert result.get("title") == "Configuration.yaml"
assert "data" in result
data = result["data"]
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_code_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError("Test Failure"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "oauth_error"
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
async def test_expired_after_exchange(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
assert result.get("type") == "abort"
assert result.get("reason") == "code_expired"
async def test_exchange_error(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test an error while exchanging the code for credentials."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
# Run one tick to invoke the credential exchange check
now = utcnow()
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
side_effect=FlowExchangeError(),
):
now += CODE_CHECK_ALARM_TIMEDELTA
await fire_alarm(hass, now)
await hass.async_block_till_done()
# Status has not updated, will retry
result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
# Run another tick, which attempts credential exchange again
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
now += CODE_CHECK_ALARM_TIMEDELTA
await fire_alarm(hass, now)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "create_entry"
assert result.get("title") == "Configuration.yaml"
assert "data" in result
data = result["data"]
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_existing_config_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
component_setup: ComponentSetup,
) -> None:
"""Test can't configure when config entry already exists."""
config_entry.add_to_hass(hass)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "already_configured"
async def test_missing_configuration(
hass: HomeAssistant,
) -> None:
"""Test can't configure when config entry already exists."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "missing_configuration"
async def test_import_config_entry_from_existing_token(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
) -> None:
"""Test setup with an existing token file."""
assert await component_setup()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
data = entries[0].data
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
async def test_reauth_flow(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test can't configure when config entry already exists."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "device_auth",
"token": {"access_token": "OLD_ACCESS_TOKEN"},
},
)
config_entry.add_to_hass(hass)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={},
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "abort"
assert result.get("reason") == "reauth_successful"
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
data = entries[0].data
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1

View File

@ -6,11 +6,6 @@ import datetime
from typing import Any from typing import Any
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
from oauth2client.client import (
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
)
import pytest import pytest
from homeassistant.components.google import ( from homeassistant.components.google import (
@ -18,6 +13,7 @@ from homeassistant.components.google import (
SERVICE_ADD_EVENT, SERVICE_ADD_EVENT,
SERVICE_SCAN_CALENDARS, SERVICE_SCAN_CALENDARS,
) )
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
@ -30,73 +26,13 @@ from .conftest import (
TEST_YAML_ENTITY_NAME, TEST_YAML_ENTITY_NAME,
ApiResult, ApiResult,
ComponentSetup, ComponentSetup,
YieldFixture,
) )
from tests.common import async_fire_time_changed from tests.common import MockConfigEntry
# Typing helpers # Typing helpers
HassApi = Callable[[], Awaitable[dict[str, Any]]] HassApi = Callable[[], Awaitable[dict[str, Any]]]
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
@pytest.fixture
async def code_expiration_delta() -> datetime.timedelta:
"""Fixture for code expiration time, defaulting to the future."""
return datetime.timedelta(minutes=3)
@pytest.fixture
async def mock_code_flow(
code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
"""Fixture for initiating OAuth flow."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
) as mock_flow:
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
yield mock_flow
@pytest.fixture
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
"""Fixture for mocking out the exchange for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
) as mock:
yield mock
@pytest.fixture
async def mock_notification() -> YieldFixture[Mock]:
"""Fixture for capturing persistent notifications."""
with patch("homeassistant.components.persistent_notification.create") as 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):
async_fire_time_changed(hass, point_in_time)
await hass.async_block_till_done()
@pytest.mark.parametrize("config", [{}])
async def test_setup_config_empty(
hass: HomeAssistant,
component_setup: ComponentSetup,
mock_notification: Mock,
):
"""Test setup component with an empty configuruation."""
assert await component_setup()
mock_notification.assert_not_called()
assert not hass.states.get(TEST_YAML_ENTITY)
def assert_state(actual: State | None, expected: State | None) -> None: def assert_state(actual: State | None, expected: State | None) -> None:
"""Assert that the two states are equal.""" """Assert that the two states are equal."""
@ -108,118 +44,29 @@ def assert_state(actual: State | None, expected: State | None) -> None:
assert actual.attributes == expected.attributes assert actual.attributes == expected.attributes
async def test_init_success( @pytest.fixture
def setup_config_entry(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Fixture to initialize the config entry."""
config_entry.add_to_hass(hass)
async def test_unload_entry(
hass: HomeAssistant, hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
mock_notification: Mock,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_calendars_yaml: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test successful creds setup.""" """Test load and unload of a ConfigEntry."""
mock_calendars_list({"items": [test_api_calendar]}) await component_setup()
assert await component_setup()
# Run one tick to invoke the credential exchange check entries = hass.config_entries.async_entries(DOMAIN)
now = utcnow() assert len(entries) == 1
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) entry = entries[0]
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get(TEST_YAML_ENTITY) assert await hass.config_entries.async_unload(entry.entry_id)
assert state assert entry.state == ConfigEntryState.NOT_LOADED
assert state.name == TEST_YAML_ENTITY_NAME
assert state.state == STATE_OFF
mock_notification.assert_called()
assert "We are all setup now" in mock_notification.call_args[0][1]
async def test_code_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test loading the integration with no existing credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError("Test Failure"),
):
assert await component_setup()
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert "Error: Test Failure" in mock_notification.call_args[0][1]
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
async def test_expired_after_exchange(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test loading the integration with no existing credentials."""
assert await component_setup()
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert (
"Authentication code expired, please restart Home-Assistant and try again"
in mock_notification.call_args[0][1]
)
async def test_exchange_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test an error while exchanging the code for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
side_effect=FlowExchangeError(),
):
assert await component_setup()
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1]
async def test_existing_token(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_notification: Mock,
) -> None:
"""Test setup with an existing token file."""
mock_calendars_list({"items": [test_api_calendar]})
assert await component_setup()
state = hass.states.get(TEST_YAML_ENTITY)
assert state
assert state.name == TEST_YAML_ENTITY_NAME
assert state.state == STATE_OFF
mock_notification.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -228,80 +75,61 @@ async def test_existing_token(
async def test_existing_token_missing_scope( async def test_existing_token_missing_scope(
hass: HomeAssistant, hass: HomeAssistant,
token_scopes: list[str], token_scopes: list[str],
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_yaml: None, config_entry: MockConfigEntry,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_notification: Mock,
mock_code_flow: Mock,
mock_exchange: Mock,
) -> None: ) -> None:
"""Test setup where existing token does not have sufficient scopes.""" """Test setup where existing token does not have sufficient scopes."""
mock_calendars_list({"items": [test_api_calendar]}) config_entry.add_to_hass(hass)
assert await component_setup() assert await component_setup()
# Run one tick to invoke the credential exchange check entries = hass.config_entries.async_entries(DOMAIN)
now = utcnow() assert len(entries) == 1
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) assert entries[0].state is ConfigEntryState.SETUP_ERROR
assert len(mock_exchange.mock_calls) == 1
state = hass.states.get(TEST_YAML_ENTITY) flows = hass.config_entries.flow.async_progress()
assert state assert len(flows) == 1
assert state.name == TEST_YAML_ENTITY_NAME assert flows[0]["step_id"] == "reauth_confirm"
assert state.state == STATE_OFF
# No notifications on success
mock_notification.assert_called()
assert "We are all setup now" in mock_notification.call_args[0][1]
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]]) @pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
async def test_calendar_yaml_missing_required_fields( async def test_calendar_yaml_missing_required_fields(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]], calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None, mock_calendars_yaml: None,
mock_notification: Mock, setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test setup with a missing schema fields, ignores the error and continues.""" """Test setup with a missing schema fields, ignores the error and continues."""
assert await component_setup() assert await component_setup()
assert not hass.states.get(TEST_YAML_ENTITY) assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_not_called()
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]]) @pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
async def test_invalid_calendar_yaml( async def test_invalid_calendar_yaml(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]], calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None, mock_calendars_yaml: None,
mock_notification: Mock, setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test setup with missing entity id fields fails to setup the integration.""" """Test setup with missing entity id fields fails to setup the config entry."""
# Integration fails to setup # Integration fails to setup
assert not await component_setup() assert await component_setup()
# XXX No config entries
assert not hass.states.get(TEST_YAML_ENTITY) assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_not_called()
async def test_calendar_yaml_error( async def test_calendar_yaml_error(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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_notification: Mock, 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]})
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()): with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
@ -344,12 +172,12 @@ async def test_calendar_yaml_error(
) )
async def test_track_new( async def test_track_new(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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_calendars_yaml: None, mock_calendars_yaml: None,
expected_state: State, expected_state: State,
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""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."""
@ -363,11 +191,11 @@ async def test_track_new(
@pytest.mark.parametrize("calendars_config", [[]]) @pytest.mark.parametrize("calendars_config", [[]])
async def test_found_calendar_from_api( async def test_found_calendar_from_api(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
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],
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test finding a calendar from the API.""" """Test finding a calendar from the API."""
@ -402,13 +230,13 @@ async def test_found_calendar_from_api(
) )
async def test_calendar_config_track_new( async def test_calendar_config_track_new(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
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],
calendars_config_track: bool, calendars_config_track: bool,
expected_state: State, expected_state: State,
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test calendar config that overrides whether or not a calendar is tracked.""" """Test calendar config that overrides whether or not a calendar is tracked."""
@ -421,11 +249,11 @@ async def test_calendar_config_track_new(
async def test_add_event( async def test_add_event(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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, mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test service call that adds an event.""" """Test service call that adds an event."""
@ -471,7 +299,6 @@ async def test_add_event(
) )
async def test_add_event_date_in_x( async def test_add_event_date_in_x(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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],
@ -479,6 +306,7 @@ async def test_add_event_date_in_x(
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,
) -> None: ) -> None:
"""Test service call that adds an event with various time ranges.""" """Test service call that adds an event with various time ranges."""
@ -514,10 +342,10 @@ async def test_add_event_date_in_x(
async def test_add_event_date( async def test_add_event_date(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
mock_insert_event: Mock, mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test service call that sets a date range.""" """Test service call that sets a date range."""
@ -554,11 +382,11 @@ async def test_add_event_date(
async def test_add_event_date_time( async def test_add_event_date_time(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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, mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> 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."""
@ -601,10 +429,10 @@ async def test_add_event_date_time(
async def test_scan_calendars( async def test_scan_calendars(
hass: HomeAssistant, hass: HomeAssistant,
mock_token_read: None,
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],
setup_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test finding a calendar from the API.""" """Test finding a calendar from the API."""